humanish 0.110.2 → 0.110.4

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 (115) hide show
  1. package/ARCHITECTURE.md +10 -15
  2. package/CHANGELOG.md +27 -0
  3. package/CONTEXT.md +1 -1
  4. package/dist/actors/computer-use/actor.js +2 -2
  5. package/dist/actors/computer-use/loop/ending.js +1 -1
  6. package/dist/actors/registry.js +1 -1
  7. package/dist/actors/scripted-browser/actor.js +17 -8
  8. package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
  9. package/dist/actors/scripted-browser/launch-env.js +50 -0
  10. package/dist/analysis/automatic-completion.d.ts +2 -1
  11. package/dist/analysis/automatic-completion.js +15 -2
  12. package/dist/cli/commands/migrate.js +1 -1
  13. package/dist/cli/commands/observe.js +3 -2
  14. package/dist/cli/commands/runs.js +5 -2
  15. package/dist/cli/commands/study-route-computer-use.js +3 -2
  16. package/dist/cli/commands/study-route-preview.js +2 -1
  17. package/dist/cli/commands/study-route-shared-world.js +3 -3
  18. package/dist/cli/commands/study-run.js +9 -19
  19. package/dist/cli/commands/study.js +4 -1
  20. package/dist/cli/findings.d.ts +5 -0
  21. package/dist/cli/findings.js +21 -0
  22. package/dist/cli/io.d.ts +2 -1
  23. package/dist/cli/io.js +3 -2
  24. package/dist/cli/observer-follow.d.ts +6 -2
  25. package/dist/cli/observer-follow.js +15 -5
  26. package/dist/evidence/redaction.js +15 -4
  27. package/dist/routes/computer-use/live-phase.js +8 -1
  28. package/dist/routes/computer-use/local-vm.js +2 -1
  29. package/dist/routes/computer-use/participant-runs.d.ts +6 -9
  30. package/dist/routes/computer-use/participant-runs.js +16 -36
  31. package/dist/routes/computer-use/plan.d.ts +2 -2
  32. package/dist/routes/computer-use/plan.js +7 -6
  33. package/dist/routes/computer-use/route.d.ts +5 -18
  34. package/dist/routes/computer-use/route.js +19 -79
  35. package/dist/routes/computer-use/setup.d.ts +3 -2
  36. package/dist/routes/computer-use/setup.js +2 -1
  37. package/dist/routes/computer-use/types.d.ts +6 -10
  38. package/dist/routes/scripted/plan.js +5 -4
  39. package/dist/routes/scripted/route.d.ts +4 -17
  40. package/dist/routes/scripted/route.js +15 -43
  41. package/dist/routes/scripted/surface-sessions.d.ts +2 -0
  42. package/dist/routes/scripted/surface-sessions.js +2 -1
  43. package/dist/routes/scripted/types.d.ts +1 -6
  44. package/dist/routes/shared-world/participant-specs.js +5 -1
  45. package/dist/routes/shared-world/plan.js +7 -6
  46. package/dist/routes/shared-world/route.d.ts +5 -11
  47. package/dist/routes/shared-world/route.js +27 -66
  48. package/dist/routes/shared-world/types.d.ts +2 -6
  49. package/dist/routes/terminal/plan.js +4 -3
  50. package/dist/routes/terminal/route.d.ts +3 -13
  51. package/dist/routes/terminal/route.js +21 -60
  52. package/dist/routes/terminal/session.d.ts +2 -3
  53. package/dist/routes/terminal/session.js +3 -3
  54. package/dist/routes/terminal/types.d.ts +2 -7
  55. package/dist/run/display.d.ts +10 -0
  56. package/dist/run/display.js +22 -0
  57. package/dist/run/reclaim.d.ts +4 -2
  58. package/dist/run/reclaim.js +61 -18
  59. package/dist/run/route-shell.d.ts +60 -0
  60. package/dist/run/route-shell.js +51 -0
  61. package/dist/run/run.d.ts +16 -1
  62. package/dist/run/run.js +33 -5
  63. package/dist/run/status.d.ts +8 -0
  64. package/dist/run/status.js +6 -0
  65. package/dist/run/stored-runs.js +26 -11
  66. package/dist/study/composition-rules.js +33 -33
  67. package/dist/study/config.d.ts +9 -2
  68. package/dist/study/config.js +14 -5
  69. package/dist/study/doctor.js +5 -4
  70. package/dist/study/init-templates.d.ts +1 -1
  71. package/dist/study/init-templates.js +43 -34
  72. package/dist/study/init.js +1 -1
  73. package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
  74. package/dist/study/{convert.js → migrate/convert.js} +29 -31
  75. package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
  76. package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
  77. package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
  78. package/dist/study/migrate/v2.d.ts +68 -0
  79. package/dist/study/migrate/v2.js +480 -0
  80. package/dist/study/parse/actors.d.ts +46 -1
  81. package/dist/study/parse/actors.js +116 -69
  82. package/dist/study/parse/execution.js +2 -2
  83. package/dist/study/parse/study-v3.d.ts +26 -0
  84. package/dist/study/parse/study-v3.js +53 -35
  85. package/dist/study/parse/study.d.ts +38 -0
  86. package/dist/study/parse/study.js +225 -0
  87. package/dist/study/parse/subject.js +1 -1
  88. package/dist/study/persona-resolve.d.ts +2 -4
  89. package/dist/study/persona-resolve.js +2 -1
  90. package/dist/study/plan-base.d.ts +1 -1
  91. package/dist/study/plan-base.js +5 -4
  92. package/dist/study/plan-participants.js +12 -11
  93. package/dist/study/plan.js +4 -3
  94. package/dist/study/preflight-probes.js +2 -2
  95. package/dist/study/preflight.js +2 -2
  96. package/dist/study/routing.js +12 -15
  97. package/dist/study/run-study-options.js +3 -3
  98. package/dist/study/study-fields.d.ts +28 -0
  99. package/dist/study/study-fields.js +51 -0
  100. package/dist/study/summary.js +13 -17
  101. package/dist/study/types.d.ts +40 -0
  102. package/dist/study/url-credentials.js +2 -1
  103. package/dist/study/validation.d.ts +2 -0
  104. package/dist/study/validation.js +46 -41
  105. package/dist/study/warnings.js +5 -4
  106. package/dist/substrates/local/runtime-config.js +9 -9
  107. package/dist/tui-app.js +3 -3
  108. package/docs/architecture/actor-contract.md +1 -1
  109. package/docs/architecture/external-public-shared-world.md +1 -1
  110. package/docs/architecture/terminal-product-route.md +13 -13
  111. package/docs/contracts/schemas.md +13 -4
  112. package/docs/contracts/study-analysis.md +5 -2
  113. package/docs/product/automatic-analysis.md +1 -1
  114. package/package.json +1 -1
  115. /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
@@ -0,0 +1,480 @@
1
+ // humanish.lab.v2, read for `humanish migrate`. checkV2 applies the v2 parser's section checks in
2
+ // its order and with its messages, and the rules on v2 keys that a humanish.study.v3 file has no
3
+ // form for. v2ToV3Raw rewrites the file as a plain v3 record, which parseStudyV3 checks against the
4
+ // rules the two formats share. The converter (convert.ts) rewrites the YAML document separately and
5
+ // compares what the two rewrites parse to.
6
+ //
7
+ // The v2-only rules are copies of the parser's and the planners' (the actor list and roster in
8
+ // parse/actors.ts, the scenario object in parse/execution.ts, and checks in validation.ts and
9
+ // composition-rules.ts), which stay there while a library config can still take the v2 shape.
10
+ import { isRecord } from "../../run/type-guards.js";
11
+ import { findUnknownV2Key } from "../keys.js";
12
+ import { parseActorFields, parseActorType, parseParticipantEntries, PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN, } from "../parse/actors.js";
13
+ import { parseComms } from "../parse/comms.js";
14
+ import { parseExecution, parsePolicies, parseReview } from "../parse/execution.js";
15
+ import { parseCaps } from "../parse/study.js";
16
+ import { parseSubject } from "../parse/subject.js";
17
+ import { posInt, str } from "../parse/values.js";
18
+ import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, routeOf, } from "../routing.js";
19
+ import { desktopMediaValidationReason, outputTokenLimitValidationReason, smtpValidationReason, } from "../validation.js";
20
+ import { ID_PATTERN, STUDY_SCHEMA, V2_SCHEMA, } from "../types.js";
21
+ function refused(message) {
22
+ return { ok: false, message };
23
+ }
24
+ // The v2 spelling of a participant list and its entries, `actors[0].lanes[3]`.
25
+ const ENTRY_PATHS = {
26
+ list: "actors[0].lanes",
27
+ entry: (index) => `actors[0].lanes[${index}]`,
28
+ };
29
+ /**
30
+ * Check a humanish.lab.v2 file the way the v2 parser does, up to the checks that read more than
31
+ * one section, and then the budget rule those checks apply first. Each refusal is that parser's
32
+ * message.
33
+ */
34
+ export function checkV2(raw) {
35
+ const unknownKey = findUnknownV2Key(raw);
36
+ if (unknownKey)
37
+ return refused(unknownKey);
38
+ const id = str(raw.id);
39
+ if (!id || !ID_PATTERN.test(id)) {
40
+ return refused("The study id must be a public-safe token starting with a letter or digit (/^[A-Za-z0-9][A-Za-z0-9_.-]*$/).");
41
+ }
42
+ const subject = parseSubject(raw.subject);
43
+ if (!subject.ok)
44
+ return refused(subject.error.message);
45
+ const actor = readActor(raw.actors);
46
+ if (!actor.ok)
47
+ return actor;
48
+ const execution = parseExecution(raw.execution);
49
+ if (!execution.ok)
50
+ return refused(execution.error.message);
51
+ const scenario = readScenario(raw.scenario);
52
+ if (!scenario.ok)
53
+ return scenario;
54
+ if (isRecord(raw.policies) &&
55
+ raw.policies.mediaPermission !== undefined &&
56
+ raw.policies.mediaPermission !== "prompt" &&
57
+ raw.policies.mediaPermission !== "granted") {
58
+ return refused("`policies.mediaPermission` must be `prompt` (the participant answers the browser's own dialog) or `granted`.");
59
+ }
60
+ const policies = parsePolicies(raw.policies);
61
+ if (!policies.ok)
62
+ return refused(policies.error.message);
63
+ const review = parseReview(raw.review);
64
+ if (!review.ok)
65
+ return refused(review.error.message);
66
+ if (isRecord(raw.defaults) && raw.defaults.open !== undefined) {
67
+ if (typeof raw.defaults.open !== "boolean")
68
+ return refused("`defaults.open` must be true or false.");
69
+ }
70
+ const comms = parseComms(raw.comms);
71
+ if (!comms.ok)
72
+ return refused(comms.error.message);
73
+ const composition = {
74
+ schema: V2_SCHEMA,
75
+ id,
76
+ subject: subject.value,
77
+ actors: [{ type: actor.value.type, ...actor.value.fields }],
78
+ ...(execution.value === undefined ? {} : { execution: execution.value }),
79
+ ...(policies.value === undefined ? {} : { policies: policies.value }),
80
+ ...(comms.value === undefined ? {} : { comms: comms.value }),
81
+ };
82
+ const route = v2RouteOf(composition);
83
+ const study = {
84
+ route,
85
+ composition,
86
+ count: actor.value.count,
87
+ entries: actor.value.entries,
88
+ focus: actor.value.focus,
89
+ executionCaps: execution.value?.caps,
90
+ scenario: scenario.value,
91
+ personas: Array.isArray(raw.personas) && raw.personas.some(isRecord),
92
+ terminalTimeout: route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined,
93
+ };
94
+ const budget = scenarioBudgetReason(study);
95
+ if (!budget)
96
+ return { ok: true, value: study };
97
+ // The checks the v2 parser runs before the budget rule, which are the first it runs across
98
+ // sections, so a file that breaks one of them and the budget rule gets the same refusal.
99
+ return refused(smtpValidationReason(composition) ??
100
+ desktopMediaValidationReason(composition) ??
101
+ outputTokenLimitValidationReason(composition) ??
102
+ budget);
103
+ }
104
+ /**
105
+ * The route a v2 file takes. `subject.topology: shared-world` declares a shared world, as
106
+ * `route: shared-world` does in v3, so a file that declares one its composition does not take gets
107
+ * the shared-world checks' refusal, as it does from the v2 parser.
108
+ */
109
+ function v2RouteOf(composition) {
110
+ return composition.subject.topology === "shared-world" ? "shared-world" : routeOf(composition);
111
+ }
112
+ // parse/actors.ts parseActors, for the one actor it accepts.
113
+ function readActor(raw) {
114
+ if (!Array.isArray(raw) || raw.length === 0) {
115
+ return refused("`actors` must be a non-empty array.");
116
+ }
117
+ if (raw.length > 1) {
118
+ return refused("Multiple actors are not supported yet (only the first actor runs); declare a single actor.");
119
+ }
120
+ const entry = raw[0];
121
+ if (!isRecord(entry))
122
+ return refused("actors[0] must be an object.");
123
+ const type = parseActorType(entry, "actors[0]");
124
+ if (!type.ok)
125
+ return refused(type.error.message);
126
+ const count = posInt(entry.count);
127
+ if (entry.lanes !== undefined && entry.roster !== undefined) {
128
+ return refused("Set either actors[0].lanes or actors[0].roster, not both: list participants one by one in `lanes`, or in groups in `roster`.");
129
+ }
130
+ if (entry.roster !== undefined && count !== undefined) {
131
+ return refused("Set either actors[0].roster or actors[0].count, not both: `roster` declares groups of distinct participants, and `count` declares identical ones.");
132
+ }
133
+ if (entry.roster !== undefined && entry.laneFocus !== undefined) {
134
+ return refused("actors[0].roster and actors[0].laneFocus are mutually exclusive: a roster group's instruction is each participant's steer.");
135
+ }
136
+ let listed = entry.lanes;
137
+ if (entry.roster !== undefined) {
138
+ const expanded = rosterEntries(entry.roster);
139
+ if (!expanded.ok)
140
+ return expanded;
141
+ listed = expanded.value;
142
+ }
143
+ const entries = parseParticipantEntries(listed, ENTRY_PATHS);
144
+ if (!entries.ok)
145
+ return refused(entries.error.message);
146
+ const fields = parseActorFields(entry, "actors[0]");
147
+ if (!fields.ok)
148
+ return refused(fields.error.message);
149
+ return {
150
+ ok: true,
151
+ value: {
152
+ type: type.value,
153
+ fields: fields.value,
154
+ count,
155
+ entries: entries.value,
156
+ focus: readFocus(entry.laneFocus),
157
+ },
158
+ };
159
+ }
160
+ const GROUP_KEYS = "{ id, count, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }";
161
+ /**
162
+ * `actors[0].roster` groups, each expanded into its participants `<id>-01` to `<id>-NN`, as written:
163
+ * the entries parse after. parse/actors.ts parseRosterGroups.
164
+ */
165
+ function rosterEntries(raw) {
166
+ if (!Array.isArray(raw) || raw.length === 0) {
167
+ return refused(`actors[0].roster must be a non-empty array of group objects (${GROUP_KEYS}) when set.`);
168
+ }
169
+ const expanded = [];
170
+ const seen = new Set();
171
+ for (const [index, group] of raw.entries()) {
172
+ if (!isRecord(group))
173
+ return refused(`actors[0].roster[${index}] must be an object (${GROUP_KEYS}).`);
174
+ const id = str(group.id);
175
+ if (id === undefined) {
176
+ return refused(`actors[0].roster[${index}].id is required and must be a public-safe token matching ${PARTICIPANT_ID_PATTERN}.`);
177
+ }
178
+ if (!PARTICIPANT_ID_PATTERN.test(id) || id.length > PARTICIPANT_ID_MAX_CHARS - 3) {
179
+ return refused(`actors[0].roster[${index}].id must match ${PARTICIPANT_ID_PATTERN} and be at most ${PARTICIPANT_ID_MAX_CHARS - 3} characters, because each generated participant id adds a suffix like "-01"; "${id}" is not.`);
180
+ }
181
+ if (seen.has(id))
182
+ return refused(`actors[0].roster group ids must be unique (duplicate "${id}").`);
183
+ seen.add(id);
184
+ const count = posInt(group.count);
185
+ if (count === undefined) {
186
+ return refused(`actors[0].roster[${index}].count is required and must be a positive integer.`);
187
+ }
188
+ const shared = { ...group };
189
+ delete shared.id;
190
+ delete shared.count;
191
+ for (let n = 1; n <= count; n += 1)
192
+ expanded.push({ ...shared, id: `${id}-${String(n).padStart(2, "0")}` });
193
+ }
194
+ return { ok: true, value: expanded };
195
+ }
196
+ // The fields of `laneFocus` that are non-empty strings; undefined when none is. parse/actors.ts
197
+ // parseFocus.
198
+ function readFocus(raw) {
199
+ if (!isRecord(raw))
200
+ return undefined;
201
+ const focus = {};
202
+ const id = str(raw.id);
203
+ if (id)
204
+ focus.id = id;
205
+ const label = str(raw.label);
206
+ if (label)
207
+ focus.label = label;
208
+ const instruction = str(raw.instruction);
209
+ if (instruction)
210
+ focus.instruction = instruction;
211
+ return Object.keys(focus).length > 0 ? focus : undefined;
212
+ }
213
+ // parse/execution.ts parseScenario: a `mode` or `ref` it cannot read is read as unset.
214
+ function readScenario(raw) {
215
+ if (!isRecord(raw))
216
+ return { ok: true, value: undefined };
217
+ const scenario = {};
218
+ const ref = str(raw.ref);
219
+ if (ref)
220
+ scenario.ref = ref;
221
+ if (isRecord(raw.inline))
222
+ scenario.inline = raw.inline;
223
+ const mode = str(raw.mode);
224
+ if (mode === "dry-run" || mode === "live")
225
+ scenario.mode = mode;
226
+ const caps = parseCaps(raw.caps);
227
+ if (!caps.ok)
228
+ return refused(caps.error.message);
229
+ if (caps.value)
230
+ scenario.caps = caps.value;
231
+ return { ok: true, value: Object.keys(scenario).length > 0 ? scenario : undefined };
232
+ }
233
+ // validation.ts scenarioCapsValidationReason. The v2 parser applies it after the SMTP, media and
234
+ // output-limit checks and before the composition checks, so checkV2 runs it with the first three.
235
+ function scenarioBudgetReason(study) {
236
+ if (!isComputerUseComposition(study.composition))
237
+ return undefined;
238
+ for (const key of ["maxUsd", "maxTotalUsd"]) {
239
+ const value = study.scenario?.caps?.[key];
240
+ if (value === undefined || value <= 0)
241
+ continue;
242
+ return `scenario.caps.${key} (${value}) does not cap a computer-use study: this route stops on execution.caps.${key}. Move the value to execution.caps.${key}; scenario.caps applies only to terminal-product studies.`;
243
+ }
244
+ return undefined;
245
+ }
246
+ /**
247
+ * The refusals the v2 parser gives for shapes a v3 file cannot express, once the checks both
248
+ * formats share have passed on the study without them. A file that also breaks a shared check
249
+ * gets that check's message.
250
+ */
251
+ export function v2ShapeReason(study) {
252
+ const { route, composition, count, entries, focus } = study;
253
+ if (route === "scripted") {
254
+ // composition-rules.ts scriptedBrowserValidationReason, in its order.
255
+ if (composition.subject.source === "clone" && composition.subject.topology !== undefined) {
256
+ return "A clone scripted-browser study does not support `subject.topology` yet: it runs one synthetic subject for its scripted actor. Remove `subject.topology`.";
257
+ }
258
+ if ((count ?? 1) > 2) {
259
+ return "A scripted-browser study takes `surfaces` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
260
+ }
261
+ if (composition.subject.source === "clone" && entries !== undefined) {
262
+ return "`participants` is not supported on the scripted-browser route yet. Use `surfaces` to choose the desktop and mobile surfaces.";
263
+ }
264
+ }
265
+ // validation.ts computerUseValidationReason.
266
+ if (isComputerUseComposition(composition) && entries !== undefined) {
267
+ if (count !== undefined) {
268
+ return "Set either `actors[0].count` (identical participants) or `actors[0].lanes` (a roster of distinct participants), not both.";
269
+ }
270
+ if (focus !== undefined) {
271
+ return "actors[0].laneFocus and actors[0].lanes are mutually exclusive: each roster entry's `instruction` is the fan-out steer; laneFocus is the steer for a single participant.";
272
+ }
273
+ }
274
+ // composition-rules.ts terminalValidationReason.
275
+ if (route === "terminal" && (count ?? 1) > 1) {
276
+ return "Terminal fan-out to more than one participant is not supported yet; set participants to 1.";
277
+ }
278
+ // substrates/local/runtime-config.ts localBrowserUnsupportedReason: a local Codex participant
279
+ // takes no dollar cap, and the v2 parser counts one in scenario.caps too.
280
+ const caps = study.scenario?.caps;
281
+ if (composition.subject.source === "app-url" &&
282
+ composition.execution?.target === "local" &&
283
+ composition.actors[0]?.type === "local-agent" &&
284
+ (caps?.maxUsd !== undefined || caps?.maxTotalUsd !== undefined)) {
285
+ return "Local Codex participants currently use gpt-6-astra at low effort. Account dollar/output-token caps are unavailable; use API participants for those controls.";
286
+ }
287
+ return undefined;
288
+ }
289
+ // The caps keys each route reads, as study-v3.ts CAPS_KEYS has them.
290
+ const CAPS_READ = {
291
+ preview: [],
292
+ "computer-use": ["maxUsd", "maxTotalUsd"],
293
+ "shared-world": ["maxUsd", "maxTotalUsd"],
294
+ terminal: ["maxUsd", "maxJobs", "maxMinutes"],
295
+ scripted: [],
296
+ };
297
+ /**
298
+ * A v2 file as a plain humanish.study.v3 record, for parseStudyV3 and parseStudy. It reads each key
299
+ * it moves the way the v2 parser reads it (`count` as a positive integer, `laneFocus` through its
300
+ * string fields, `roster` groups expanded, `scenario.ref` and `scenario.mode` as strings), and
301
+ * leaves out every key no v3 file can hold, so it parses whenever the v2 parser would. A caps
302
+ * block keeps the keys its route reads. A shared world keeps a count or no list, which only
303
+ * parseStudyV3's report mode takes.
304
+ */
305
+ export function v2ToV3Raw(raw, route) {
306
+ const listed = Array.isArray(raw.actors) && isRecord(raw.actors[0]) ? raw.actors[0] : {};
307
+ const { count: rawCount, lanes, roster, laneFocus, ...actor } = listed;
308
+ const count = posInt(rawCount);
309
+ const expanded = roster === undefined ? undefined : rosterEntries(roster);
310
+ const entries = expanded?.ok ? expanded.value : Array.isArray(lanes) ? lanes : undefined;
311
+ const instruction = readFocus(laneFocus)?.instruction;
312
+ const scenario = isRecord(raw.scenario) ? raw.scenario : {};
313
+ const mode = str(scenario.mode);
314
+ const ref = str(scenario.ref);
315
+ let participants;
316
+ if (route === "preview")
317
+ participants = count;
318
+ else if (route === "computer-use" || route === "shared-world") {
319
+ participants =
320
+ entries ??
321
+ (instruction === undefined
322
+ ? count
323
+ : { ...(count === undefined ? {} : { count }), instruction });
324
+ }
325
+ const surfaces = route !== "scripted"
326
+ ? undefined
327
+ : count === 2
328
+ ? ["desktop", "mobile"]
329
+ : count === 1
330
+ ? ["desktop"]
331
+ : undefined;
332
+ const block = route === "terminal" ? scenario.caps : isRecord(raw.execution) ? raw.execution.caps : undefined;
333
+ const caps = isRecord(block)
334
+ ? Object.fromEntries(Object.entries(block).filter(([key]) => CAPS_READ[route].includes(key)))
335
+ : {};
336
+ const execution = isRecord(raw.execution) ? { ...raw.execution } : undefined;
337
+ delete execution?.caps;
338
+ if (route === "terminal")
339
+ delete execution?.timeoutMs;
340
+ const subject = isRecord(raw.subject) ? { ...raw.subject } : raw.subject;
341
+ if (isRecord(subject))
342
+ delete subject.topology;
343
+ const v3 = { schema: STUDY_SCHEMA };
344
+ const fields = [
345
+ ["id", raw.id],
346
+ ["title", raw.title],
347
+ ["description", raw.description],
348
+ ["route", route],
349
+ ["mode", mode === "dry-run" || mode === "live" ? mode : undefined],
350
+ ["subject", subject],
351
+ ["actor", actor],
352
+ ["participants", participants],
353
+ ["surfaces", surfaces],
354
+ ["caps", Object.keys(caps).length > 0 ? caps : undefined],
355
+ ["execution", execution],
356
+ ["scenario", ref],
357
+ ["policies", raw.policies],
358
+ ["review", raw.review],
359
+ ["defaults", raw.defaults],
360
+ ["comms", raw.comms],
361
+ ];
362
+ for (const [key, value] of fields)
363
+ if (value !== undefined)
364
+ v3[key] = value;
365
+ return v3;
366
+ }
367
+ function routesOf({ composition }) {
368
+ return {
369
+ cua: isComputerUseComposition(composition),
370
+ terminal: isTerminalProductComposition(composition),
371
+ scripted: isScriptedBrowserComposition(composition),
372
+ shared: isSharedWorldComposition(composition),
373
+ };
374
+ }
375
+ // The keys that have no v3 form on the file's route, which the v2 parser's inert-field table
376
+ // (warnings.ts) lists. The computer-use and terminal routes read the `laneFocus` instruction, and
377
+ // only the computer-use routes read a list.
378
+ const V2_DROP_ROWS = [
379
+ {
380
+ path: "actors[0].laneFocus.id",
381
+ applies: (study, routes) => (routes.cua || routes.terminal) && study.focus?.id !== undefined,
382
+ },
383
+ {
384
+ path: "actors[0].laneFocus.label",
385
+ applies: (study, routes) => (routes.cua || routes.terminal) && study.focus?.label !== undefined,
386
+ },
387
+ {
388
+ path: "actors[0].laneFocus",
389
+ applies: (study, routes) => !routes.cua && !routes.terminal && study.focus !== undefined,
390
+ },
391
+ {
392
+ path: "actors[0].lanes",
393
+ applies: (study, routes) => !routes.cua && study.entries !== undefined,
394
+ },
395
+ {
396
+ path: "subject.topology",
397
+ applies: (study, routes) => !routes.shared && study.composition.subject.topology !== undefined,
398
+ },
399
+ {
400
+ path: "execution.caps",
401
+ applies: (study, routes) => !routes.cua && study.executionCaps !== undefined,
402
+ },
403
+ {
404
+ path: "scenario.caps",
405
+ applies: (study, routes) => !routes.terminal && study.scenario?.caps !== undefined,
406
+ },
407
+ {
408
+ path: "scenario.caps.maxTotalUsd",
409
+ applies: (study, routes) => routes.terminal && study.scenario?.caps?.maxTotalUsd !== undefined,
410
+ },
411
+ { path: "scenario.inline", applies: (study) => study.scenario?.inline !== undefined },
412
+ { path: "personas", applies: (study) => study.personas },
413
+ ];
414
+ // Every key migrate may drop, in the order the inert-field table lists them, which is the order
415
+ // migrate drops and reports them.
416
+ const DROP_ORDER = [
417
+ "actors[0].lanes[].entry",
418
+ "actors[0].lanes[].host",
419
+ "actors[0].laneFocus.id",
420
+ "actors[0].laneFocus.label",
421
+ "actors[0].mission",
422
+ "actors[0].laneFocus",
423
+ "actors[0].persona",
424
+ "actors[0].model",
425
+ "actors[0].lanes",
426
+ "subject.clone.depth",
427
+ "subject.serve",
428
+ "subject.env",
429
+ "subject.state",
430
+ "subject.topology",
431
+ "subject.state.checkpoint",
432
+ "subject.publicTarget",
433
+ "subject.exposure",
434
+ "comms.email",
435
+ "comms.email.external",
436
+ "execution.timeoutMs",
437
+ "execution.completionTimeoutMs",
438
+ "execution.concurrency",
439
+ "execution.caps",
440
+ "subject.product",
441
+ "scenario.caps",
442
+ "scenario.caps.maxTotalUsd",
443
+ "execution.terminal",
444
+ "execution.runtimeAuth",
445
+ "execution.runtime",
446
+ "execution.desktop.resolution",
447
+ "execution.desktop.device",
448
+ "execution.desktop.browser",
449
+ "execution.desktop.fidelity",
450
+ "execution.desktop.sandboxTimeoutMs",
451
+ "execution.desktop.template",
452
+ "execution.desktop.codexAppServer",
453
+ "scenario.ref",
454
+ "scenario.inline",
455
+ "review.scoring",
456
+ "review.milestones",
457
+ "review.vocabulary",
458
+ "review.scorer",
459
+ "personas",
460
+ ];
461
+ /**
462
+ * The keys migrate drops: the v2-only rows above and `inert`, the fields parseStudyV3 reports the
463
+ * route does not read, then terminal `execution.timeoutMs`, which no terminal code reads. Parents
464
+ * come before their children, so a child under a dropped parent is reported with the parent.
465
+ */
466
+ export function droppedPaths(study, inert) {
467
+ const routes = routesOf(study);
468
+ const found = new Set([
469
+ ...inert,
470
+ ...V2_DROP_ROWS.filter((row) => row.applies(study, routes)).map((row) => row.path),
471
+ ]);
472
+ const paths = [
473
+ ...DROP_ORDER.filter((path) => found.has(path)),
474
+ ...[...found].filter((path) => !DROP_ORDER.includes(path)),
475
+ ];
476
+ if (study.terminalTimeout)
477
+ paths.push("execution.timeoutMs");
478
+ const depth = (path) => path.split(".").length;
479
+ return [...new Set(paths)].sort((left, right) => depth(left) - depth(right));
480
+ }
@@ -1,4 +1,4 @@
1
- import type { StudyActor, StudyParticipantEntry, StudyParticipantFocus, StudyParseFailure } from "../types.js";
1
+ import type { StudyActor, StudyParticipantEntry, StudyParticipantFocus, StudyParseFailure, StudyV3 } from "../types.js";
2
2
  export declare const PARTICIPANT_ID_PATTERN: RegExp;
3
3
  export declare const PARTICIPANT_ID_MAX_CHARS = 40;
4
4
  /** The participant entries an actor declares, after roster groups expand into them. The manifest
@@ -12,3 +12,48 @@ export declare function parseActors(raw: unknown): {
12
12
  ok: true;
13
13
  value: StudyActor[];
14
14
  } | StudyParseFailure;
15
+ /** An actor's `type`, which a study route must run. `path` names the actor in messages. */
16
+ export declare function parseActorType(entry: Record<string, unknown>, path: string): {
17
+ ok: true;
18
+ value: string;
19
+ } | StudyParseFailure;
20
+ /** An actor's keys other than `type` and the keys that declare its participants. */
21
+ type ActorFields = Omit<StudyV3["actor"], "type">;
22
+ /**
23
+ * Every actor key other than `type` and the participant keys. The v2 parser reads them after the
24
+ * participant entries, so a file with errors in both reports the entry first.
25
+ */
26
+ export declare function parseActorFields(entry: Record<string, unknown>, path: string): {
27
+ ok: true;
28
+ value: ActorFields;
29
+ } | StudyParseFailure;
30
+ /** The participants a v2 actor declares, which a v3 study declares outside its actor. */
31
+ interface ActorParticipants {
32
+ readonly count?: number | undefined;
33
+ readonly entries?: StudyParticipantEntry[] | undefined;
34
+ readonly instruction?: string | undefined;
35
+ }
36
+ /** A v3 study's actor in the v2 spelling: the actor holds its participants. */
37
+ export declare function withParticipants(actor: StudyV3["actor"], { count, entries, instruction }: ActorParticipants): StudyActor;
38
+ /** A v2 actor without the keys that declare its participants: the actor of a v3 study. */
39
+ export declare function withoutParticipants(actor: StudyActor): StudyV3["actor"];
40
+ /** How a participant list's messages name the list and each entry in it. */
41
+ export interface ParticipantPaths {
42
+ readonly list: string;
43
+ readonly entry: (index: number) => string;
44
+ }
45
+ /**
46
+ * Parse a participant list into a fan-out roster (computer-use E2B route): `actors[index].lanes` in
47
+ * a v2 file, `participants` in a v3 one, each named in messages by `paths`. Structural only:
48
+ * each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?,
49
+ * entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
50
+ * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
51
+ * must be public-safe tokens; a device must be a known preset name. The
52
+ * route-scoped cross-validation (`lanes` XOR `count`/`laneFocus`, device XOR raw resolution, cap 16)
53
+ * runs in parseStudy where the route is known.
54
+ */
55
+ export declare function parseParticipantEntries(raw: unknown, paths: ParticipantPaths): {
56
+ ok: true;
57
+ value: StudyParticipantEntry[] | undefined;
58
+ } | StudyParseFailure;
59
+ export {};