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
@@ -46,14 +46,11 @@ export function parseActors(raw) {
46
46
  if (!isRecord(entry)) {
47
47
  return invalid(`actors[${index}] must be an object.`);
48
48
  }
49
- const type = str(entry.type);
50
- if (!type) {
51
- return invalid(`actors[${index}].type is required.`);
52
- }
53
- if (REMOVED_ACTOR_TYPES.has(type)) {
54
- return invalid(`actors[${index}].type "${type}" is no longer a humanish actor. Use an actor a study route runs (one of: ${routableActorTypes().join(", ")}). To drive a study with a signed-in Claude Code, use type: local-agent with localAgent: claude.`);
55
- }
56
- const actor = { type };
49
+ const path = `actors[${index}]`;
50
+ const type = parseActorType(entry, path);
51
+ if (!type.ok)
52
+ return type;
53
+ const actor = { type: type.value };
57
54
  const count = posInt(entry.count);
58
55
  if (count !== undefined)
59
56
  actor.count = count;
@@ -68,54 +65,16 @@ export function parseActors(raw) {
68
65
  }
69
66
  const rosterResult = entry.roster !== undefined
70
67
  ? parseRosterGroups(entry.roster, index)
71
- : parseParticipantEntries(entry.lanes, index);
68
+ : parseParticipantEntries(entry.lanes, lanePaths(index));
72
69
  if (!rosterResult.ok) {
73
70
  return rosterResult;
74
71
  }
75
72
  if (rosterResult.value)
76
73
  actor.lanes = rosterResult.value;
77
- const persona = str(entry.persona);
78
- if (persona)
79
- actor.persona = persona;
80
- const mission = str(entry.mission);
81
- if (mission)
82
- actor.mission = mission;
83
- const model = str(entry.model);
84
- if (model)
85
- actor.model = model;
86
- if (entry.maxOutputTokens !== undefined) {
87
- if (!isMaxOutputTokens(entry.maxOutputTokens))
88
- return invalid(`actors[${index}].maxOutputTokens must be a positive safe integer.`);
89
- actor.maxOutputTokens = entry.maxOutputTokens;
90
- }
91
- const localAgent = str(entry.localAgent);
92
- if (entry.localAgent !== undefined) {
93
- if (localAgent !== "codex" && localAgent !== "claude") {
94
- return invalid(`actors[${index}].localAgent must be "codex" or "claude" (the locally signed-in CLI that drives the study).`);
95
- }
96
- actor.localAgent = localAgent;
97
- }
98
- if (entry.reasoningEffort !== undefined) {
99
- if (!isReasoningEffort(entry.reasoningEffort)) {
100
- return invalid(`actors[${index}].reasoningEffort must be one of: ${reasoningEffortNames()}. Support is model-dependent, so a level this model does not accept fails on the first turn rather than being silently downgraded.`);
101
- }
102
- actor.reasoningEffort = entry.reasoningEffort;
103
- }
104
- const stopWhenResult = parseStopWhen(entry.stopWhen, `actors[${index}].stopWhen`);
105
- if (!stopWhenResult.ok)
106
- return stopWhenResult;
107
- if (stopWhenResult.value !== undefined)
108
- actor.stopWhen = stopWhenResult.value;
109
- const dwellResult = parseDwell(entry.dwell, `actors[${index}].dwell`);
110
- if (!dwellResult.ok)
111
- return dwellResult;
112
- if (dwellResult.value !== undefined)
113
- actor.dwell = dwellResult.value;
114
- const tasksResult = parseTasks(entry.tasks, `actors[${index}].tasks`);
115
- if (!tasksResult.ok)
116
- return tasksResult;
117
- if (tasksResult.value !== undefined)
118
- actor.tasks = tasksResult.value;
74
+ const fields = parseActorFields(entry, path);
75
+ if (!fields.ok)
76
+ return fields;
77
+ Object.assign(actor, fields.value);
119
78
  const focus = parseFocus(entry.laneFocus);
120
79
  if (focus)
121
80
  actor.laneFocus = focus;
@@ -123,6 +82,86 @@ export function parseActors(raw) {
123
82
  }
124
83
  return { ok: true, value: actors };
125
84
  }
85
+ /** An actor's `type`, which a study route must run. `path` names the actor in messages. */
86
+ export function parseActorType(entry, path) {
87
+ const type = str(entry.type);
88
+ if (!type) {
89
+ return invalid(`${path}.type is required.`);
90
+ }
91
+ if (REMOVED_ACTOR_TYPES.has(type)) {
92
+ return invalid(`${path}.type "${type}" is no longer a humanish actor. Use an actor a study route runs (one of: ${routableActorTypes().join(", ")}). To drive a study with a signed-in Claude Code, use type: local-agent with localAgent: claude.`);
93
+ }
94
+ return { ok: true, value: type };
95
+ }
96
+ /**
97
+ * Every actor key other than `type` and the participant keys. The v2 parser reads them after the
98
+ * participant entries, so a file with errors in both reports the entry first.
99
+ */
100
+ export function parseActorFields(entry, path) {
101
+ const actor = {};
102
+ const persona = str(entry.persona);
103
+ if (persona)
104
+ actor.persona = persona;
105
+ const mission = str(entry.mission);
106
+ if (mission)
107
+ actor.mission = mission;
108
+ const model = str(entry.model);
109
+ if (model)
110
+ actor.model = model;
111
+ if (entry.maxOutputTokens !== undefined) {
112
+ if (!isMaxOutputTokens(entry.maxOutputTokens))
113
+ return invalid(`${path}.maxOutputTokens must be a positive safe integer.`);
114
+ actor.maxOutputTokens = entry.maxOutputTokens;
115
+ }
116
+ const localAgent = str(entry.localAgent);
117
+ if (entry.localAgent !== undefined) {
118
+ if (localAgent !== "codex" && localAgent !== "claude") {
119
+ return invalid(`${path}.localAgent must be "codex" or "claude" (the locally signed-in CLI that drives the study).`);
120
+ }
121
+ actor.localAgent = localAgent;
122
+ }
123
+ if (entry.reasoningEffort !== undefined) {
124
+ if (!isReasoningEffort(entry.reasoningEffort)) {
125
+ return invalid(`${path}.reasoningEffort must be one of: ${reasoningEffortNames()}. Support is model-dependent, so a level this model does not accept fails on the first turn rather than being silently downgraded.`);
126
+ }
127
+ actor.reasoningEffort = entry.reasoningEffort;
128
+ }
129
+ const stopWhenResult = parseStopWhen(entry.stopWhen, `${path}.stopWhen`);
130
+ if (!stopWhenResult.ok)
131
+ return stopWhenResult;
132
+ if (stopWhenResult.value !== undefined)
133
+ actor.stopWhen = stopWhenResult.value;
134
+ const dwellResult = parseDwell(entry.dwell, `${path}.dwell`);
135
+ if (!dwellResult.ok)
136
+ return dwellResult;
137
+ if (dwellResult.value !== undefined)
138
+ actor.dwell = dwellResult.value;
139
+ const tasksResult = parseTasks(entry.tasks, `${path}.tasks`);
140
+ if (!tasksResult.ok)
141
+ return tasksResult;
142
+ if (tasksResult.value !== undefined)
143
+ actor.tasks = tasksResult.value;
144
+ return { ok: true, value: actor };
145
+ }
146
+ /** A v3 study's actor in the v2 spelling: the actor holds its participants. */
147
+ export function withParticipants(actor, { count, entries, instruction }) {
148
+ const { type, ...fields } = actor;
149
+ return {
150
+ type,
151
+ ...(count === undefined ? {} : { count }),
152
+ ...(entries === undefined ? {} : { lanes: entries }),
153
+ ...fields,
154
+ ...(instruction === undefined ? {} : { laneFocus: { instruction } }),
155
+ };
156
+ }
157
+ /** A v2 actor without the keys that declare its participants: the actor of a v3 study. */
158
+ export function withoutParticipants(actor) {
159
+ const fields = { ...actor };
160
+ delete fields.count;
161
+ delete fields.lanes;
162
+ delete fields.laneFocus;
163
+ return fields;
164
+ }
126
165
  function parseFocus(raw) {
127
166
  if (!isRecord(raw)) {
128
167
  return undefined;
@@ -181,10 +220,18 @@ function parseRosterGroups(raw, actorIndex) {
181
220
  });
182
221
  }
183
222
  }
184
- return parseParticipantEntries(expanded, actorIndex);
223
+ return parseParticipantEntries(expanded, lanePaths(actorIndex));
224
+ }
225
+ // The v2 spelling, `actors[0].lanes[3]`. A roster group's participants are named the same way.
226
+ function lanePaths(actorIndex) {
227
+ return {
228
+ list: `actors[${actorIndex}].lanes`,
229
+ entry: (index) => `actors[${actorIndex}].lanes[${index}]`,
230
+ };
185
231
  }
186
232
  /**
187
- * Parse `actors[index].lanes` into a fan-out roster (computer-use E2B route). Structural only:
233
+ * Parse a participant list into a fan-out roster (computer-use E2B route): `actors[index].lanes` in
234
+ * a v2 file, `participants` in a v3 one, each named in messages by `paths`. Structural only:
188
235
  * each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?,
189
236
  * entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
190
237
  * Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
@@ -192,27 +239,27 @@ function parseRosterGroups(raw, actorIndex) {
192
239
  * route-scoped cross-validation (`lanes` XOR `count`/`laneFocus`, device XOR raw resolution, cap 16)
193
240
  * runs in parseStudy where the route is known.
194
241
  */
195
- function parseParticipantEntries(raw, actorIndex) {
242
+ export function parseParticipantEntries(raw, paths) {
196
243
  if (raw === undefined) {
197
244
  return { ok: true, value: undefined };
198
245
  }
199
246
  if (!Array.isArray(raw) || raw.length === 0) {
200
- return invalid(`actors[${actorIndex}].lanes must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
247
+ return invalid(`${paths.list} must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
201
248
  }
202
249
  const entries = [];
203
250
  const seenIds = new Set();
204
251
  for (const [entryIndex, entry] of raw.entries()) {
205
252
  if (!isRecord(entry)) {
206
- return invalid(`actors[${actorIndex}].lanes[${entryIndex}] must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }).`);
253
+ return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }).`);
207
254
  }
208
255
  const parsedEntry = {};
209
256
  const id = str(entry.id);
210
257
  if (id !== undefined) {
211
258
  if (!PARTICIPANT_ID_PATTERN.test(id) || id.length > PARTICIPANT_ID_MAX_CHARS) {
212
- return invalid(`actors[${actorIndex}].lanes[${entryIndex}].id must be a public-safe token matching ${PARTICIPANT_ID_PATTERN} and at most ${PARTICIPANT_ID_MAX_CHARS} chars (it names the participant's evidence paths); got "${id}".`);
259
+ return invalid(`${paths.entry(entryIndex)}.id must be a public-safe token matching ${PARTICIPANT_ID_PATTERN} and at most ${PARTICIPANT_ID_MAX_CHARS} chars (it names the participant's evidence paths); got "${id}".`);
213
260
  }
214
261
  if (seenIds.has(id)) {
215
- return invalid(`actors[${actorIndex}].lanes ids must be unique (duplicate "${id}").`);
262
+ return invalid(`${paths.list} ids must be unique (duplicate "${id}").`);
216
263
  }
217
264
  seenIds.add(id);
218
265
  parsedEntry.id = id;
@@ -220,24 +267,24 @@ function parseParticipantEntries(raw, actorIndex) {
220
267
  const device = str(entry.device);
221
268
  if (device !== undefined) {
222
269
  if (!isDevicePresetName(device)) {
223
- return invalid(`actors[${actorIndex}].lanes[${entryIndex}].device must be one of: ${DEVICE_PRESET_NAMES.join(", ")}.`);
270
+ return invalid(`${paths.entry(entryIndex)}.device must be one of: ${DEVICE_PRESET_NAMES.join(", ")}.`);
224
271
  }
225
272
  parsedEntry.device = device;
226
273
  }
227
274
  const persona = str(entry.persona);
228
275
  if (persona !== undefined)
229
276
  parsedEntry.persona = persona;
230
- const actorType = parseEntryMetadata(entry.actorType, `actors[${actorIndex}].lanes[${entryIndex}].actorType`);
277
+ const actorType = parseEntryMetadata(entry.actorType, `${paths.entry(entryIndex)}.actorType`);
231
278
  if (!actorType.ok)
232
279
  return actorType;
233
280
  if (actorType.value !== undefined)
234
281
  parsedEntry.actorType = actorType.value;
235
- const surface = parseEntryMetadata(entry.surface, `actors[${actorIndex}].lanes[${entryIndex}].surface`);
282
+ const surface = parseEntryMetadata(entry.surface, `${paths.entry(entryIndex)}.surface`);
236
283
  if (!surface.ok)
237
284
  return surface;
238
285
  if (surface.value !== undefined)
239
286
  parsedEntry.surface = surface.value;
240
- const caseGroup = parseEntryMetadata(entry.caseGroup, `actors[${actorIndex}].lanes[${entryIndex}].caseGroup`);
287
+ const caseGroup = parseEntryMetadata(entry.caseGroup, `${paths.entry(entryIndex)}.caseGroup`);
241
288
  if (!caseGroup.ok)
242
289
  return caseGroup;
243
290
  if (caseGroup.value !== undefined)
@@ -245,28 +292,28 @@ function parseParticipantEntries(raw, actorIndex) {
245
292
  const instruction = str(entry.instruction);
246
293
  if (instruction !== undefined)
247
294
  parsedEntry.instruction = instruction;
248
- const stopWhenResult = parseStopWhen(entry.stopWhen, `actors[${actorIndex}].lanes[${entryIndex}].stopWhen`);
295
+ const stopWhenResult = parseStopWhen(entry.stopWhen, `${paths.entry(entryIndex)}.stopWhen`);
249
296
  if (!stopWhenResult.ok)
250
297
  return stopWhenResult;
251
298
  if (stopWhenResult.value !== undefined)
252
299
  parsedEntry.stopWhen = stopWhenResult.value;
253
- const dwellResult = parseDwell(entry.dwell, `actors[${actorIndex}].lanes[${entryIndex}].dwell`);
300
+ const dwellResult = parseDwell(entry.dwell, `${paths.entry(entryIndex)}.dwell`);
254
301
  if (!dwellResult.ok)
255
302
  return dwellResult;
256
303
  if (dwellResult.value !== undefined)
257
304
  parsedEntry.dwell = dwellResult.value;
258
305
  if (entry.reasoningEffort !== undefined) {
259
306
  if (!isReasoningEffort(entry.reasoningEffort)) {
260
- return invalid(`actors[${actorIndex}].lanes[${entryIndex}].reasoningEffort must be one of: ${reasoningEffortNames()}. Support is model-dependent, so a level this model does not accept fails on the first turn rather than being silently downgraded.`);
307
+ return invalid(`${paths.entry(entryIndex)}.reasoningEffort must be one of: ${reasoningEffortNames()}. Support is model-dependent, so a level this model does not accept fails on the first turn rather than being silently downgraded.`);
261
308
  }
262
309
  parsedEntry.reasoningEffort = entry.reasoningEffort;
263
310
  }
264
311
  const target = str(entry.target);
265
312
  if (target !== undefined) {
266
313
  if (!isHttpUrl(target)) {
267
- return invalid(`actors[${actorIndex}].lanes[${entryIndex}].target must be an absolute http(s) URL.`);
314
+ return invalid(`${paths.entry(entryIndex)}.target must be an absolute http(s) URL.`);
268
315
  }
269
- const credential = urlCredentialReason(`actors[${actorIndex}].lanes[${entryIndex}].target`, target);
316
+ const credential = urlCredentialReason(`${paths.entry(entryIndex)}.target`, target);
270
317
  if (credential)
271
318
  return invalid(credential);
272
319
  parsedEntry.target = target;
@@ -275,7 +322,7 @@ function parseParticipantEntries(raw, actorIndex) {
275
322
  // it runs in sharedWorldValidationReason (where the route + serve.url are known).
276
323
  const entryPath = str(entry.entry);
277
324
  if (entryPath !== undefined) {
278
- const credential = entryCredentialReason(`actors[${actorIndex}].lanes[${entryIndex}].entry`, entryPath);
325
+ const credential = entryCredentialReason(`${paths.entry(entryIndex)}.entry`, entryPath);
279
326
  if (credential)
280
327
  return invalid(credential);
281
328
  parsedEntry.entry = entryPath;
@@ -284,7 +331,7 @@ function parseParticipantEntries(raw, actorIndex) {
284
331
  // exactly-one-host check runs in externalPublicSharedWorldValidationReason (route context).
285
332
  if (entry.host !== undefined) {
286
333
  if (typeof entry.host !== "boolean") {
287
- return invalid(`actors[${actorIndex}].lanes[${entryIndex}].host must be a boolean (marks the designated host participant on the external-public shared-world route).`);
334
+ return invalid(`${paths.entry(entryIndex)}.host must be a boolean (marks the designated host participant on the external-public shared-world route).`);
288
335
  }
289
336
  if (entry.host)
290
337
  parsedEntry.host = true;
@@ -255,7 +255,7 @@ function parseCaps(raw) {
255
255
  return { ok: true, value: undefined };
256
256
  }
257
257
  if (!isRecord(raw)) {
258
- return invalid("`scenario.caps` must be an object ({ maxUsd?, maxTotalUsd?, maxJobs?, maxMinutes? }).");
258
+ return invalid("`caps` must be an object ({ maxUsd?, maxTotalUsd?, maxJobs?, maxMinutes? }).");
259
259
  }
260
260
  const caps = {};
261
261
  for (const key of ["maxUsd", "maxTotalUsd", "maxJobs", "maxMinutes"]) {
@@ -263,7 +263,7 @@ function parseCaps(raw) {
263
263
  continue;
264
264
  const value = nonNegNumber(raw[key]);
265
265
  if (value === undefined) {
266
- return invalid(`\`scenario.caps.${key}\` must be a non-negative number.`);
266
+ return invalid(`\`caps.${key}\` must be a non-negative number.`);
267
267
  }
268
268
  caps[key] = value;
269
269
  }
@@ -7,12 +7,38 @@ export interface StudyDocument {
7
7
  /** For each expanded participant, the index of the `participants` entry it came from. */
8
8
  readonly participantSource: readonly number[];
9
9
  }
10
+ /** A document's `participants`, read before the participant entries parse. */
11
+ export interface Participants {
12
+ count?: number;
13
+ instruction?: unknown;
14
+ /** The list entries, with each entry that has a count expanded into its participants. */
15
+ entries?: unknown[];
16
+ /** For each expanded entry, the index of the `participants` entry it came from. */
17
+ source: number[];
18
+ }
19
+ /** What a v3 document declares before its sections parse. */
20
+ export interface StudyFront {
21
+ readonly route: StudyRoute;
22
+ readonly actor: Record<string, unknown>;
23
+ readonly participants: Participants;
24
+ /** The scripted surfaces as a count: 1 is desktop, 2 adds mobile. */
25
+ readonly surfaces: number | undefined;
26
+ }
10
27
  type Parsed<T> = {
11
28
  ok: true;
12
29
  value: T;
13
30
  } | StudyParseFailure;
14
31
  /** Rewrite a humanish.study.v3 document into humanish.lab.v2, or refuse it. */
15
32
  export declare function studyToV2(raw: Record<string, unknown>): Parsed<StudyDocument>;
33
+ /**
34
+ * The checks a v3 document passes before its sections parse: no v2 key and no unknown key, a
35
+ * route, a mode, an `actor` object, a scenario string, and the `participants`, `surfaces` and
36
+ * `caps` keys its route takes. `anySharedWorldParticipants`, for migrate's v3 form of a v2 file:
37
+ * a shared world takes `participants` in every form computer use takes, or none, because a v2
38
+ * shared world could declare a count or no list. The shared-world checks then refuse it, as the v2
39
+ * parser does.
40
+ */
41
+ export declare function readStudyFront(raw: Record<string, unknown>, anySharedWorldParticipants?: boolean): Parsed<StudyFront>;
16
42
  /**
17
43
  * Spell a v2 parser message the way the study file is written: `actors[0].lanes[3]` becomes the
18
44
  * `participants` entry it came from, `execution.caps` and `scenario.caps` become `caps`, and so on.
@@ -26,46 +26,18 @@ const CAPS_KEYS = {
26
26
  };
27
27
  /** Rewrite a humanish.study.v3 document into humanish.lab.v2, or refuse it. */
28
28
  export function studyToV2(raw) {
29
- const moved = movedKeyReason(raw);
30
- if (moved)
31
- return invalid(moved);
32
- const unknownKey = findUnknownStudyKey(raw);
33
- if (unknownKey)
34
- return invalid(unknownKey);
35
- const route = ROUTES.find((name) => name === raw.route);
36
- if (route === undefined) {
37
- return invalid(`A study needs \`route:\`, one of ${ROUTES.join(", ")}.`);
38
- }
39
- if (raw.mode !== undefined && raw.mode !== "dry-run" && raw.mode !== "live") {
40
- return invalid("`mode` must be `dry-run` or `live`. Leave it out for a dry run.");
41
- }
42
- if (!isRecord(raw.actor)) {
43
- return invalid("A study needs `actor:`, an object with at least `type`.");
44
- }
45
- if (raw.scenario !== undefined && typeof raw.scenario !== "string") {
46
- return invalid("`scenario` is a scenario id or a path to one, as a string.");
47
- }
48
- const participants = participantsOf(route, raw.participants);
49
- if (!participants.ok)
50
- return participants;
51
- const surfaces = surfaceCount(route, raw.surfaces);
52
- if (!surfaces.ok)
53
- return surfaces;
54
- const capsReason = capsKeysReason(route, raw.caps);
55
- if (capsReason)
56
- return invalid(capsReason);
57
- // The v2 inert-field table does not list this one, but the terminal planner never reads it.
58
- if (route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined) {
59
- return invalid("route: terminal does not read `execution.timeoutMs`: `caps.maxMinutes` is the command deadline. Remove it.");
60
- }
61
- const { count, instruction, entries, source } = participants.value;
29
+ const front = readStudyFront(raw);
30
+ if (!front.ok)
31
+ return front;
32
+ const { route } = front.value;
33
+ const { count, instruction, entries, source } = front.value.participants;
62
34
  // The v2 actor keys that hold participants.
63
35
  const v2Participants = [
64
- ["count", count ?? surfaces.value],
36
+ ["count", count ?? front.value.surfaces],
65
37
  ["laneFocus", instruction === undefined ? undefined : { instruction }],
66
38
  ["lanes", entries],
67
39
  ];
68
- const actor = { ...raw.actor };
40
+ const actor = { ...front.value.actor };
69
41
  for (const [key, value] of v2Participants)
70
42
  if (value !== undefined)
71
43
  actor[key] = value;
@@ -100,6 +72,52 @@ export function studyToV2(raw) {
100
72
  v2[key] = value;
101
73
  return { ok: true, value: { route, v2, participantSource: source } };
102
74
  }
75
+ /**
76
+ * The checks a v3 document passes before its sections parse: no v2 key and no unknown key, a
77
+ * route, a mode, an `actor` object, a scenario string, and the `participants`, `surfaces` and
78
+ * `caps` keys its route takes. `anySharedWorldParticipants`, for migrate's v3 form of a v2 file:
79
+ * a shared world takes `participants` in every form computer use takes, or none, because a v2
80
+ * shared world could declare a count or no list. The shared-world checks then refuse it, as the v2
81
+ * parser does.
82
+ */
83
+ export function readStudyFront(raw, anySharedWorldParticipants = false) {
84
+ const moved = movedKeyReason(raw);
85
+ if (moved)
86
+ return invalid(moved);
87
+ const unknownKey = findUnknownStudyKey(raw);
88
+ if (unknownKey)
89
+ return invalid(unknownKey);
90
+ const route = ROUTES.find((name) => name === raw.route);
91
+ if (route === undefined) {
92
+ return invalid(`A study needs \`route:\`, one of ${ROUTES.join(", ")}.`);
93
+ }
94
+ if (raw.mode !== undefined && raw.mode !== "dry-run" && raw.mode !== "live") {
95
+ return invalid("`mode` must be `dry-run` or `live`. Leave it out for a dry run.");
96
+ }
97
+ if (!isRecord(raw.actor)) {
98
+ return invalid("A study needs `actor:`, an object with at least `type`.");
99
+ }
100
+ if (raw.scenario !== undefined && typeof raw.scenario !== "string") {
101
+ return invalid("`scenario` is a scenario id or a path to one, as a string.");
102
+ }
103
+ const participants = participantsOf(anySharedWorldParticipants && route === "shared-world" ? "computer-use" : route, raw.participants);
104
+ if (!participants.ok)
105
+ return participants;
106
+ const surfaces = surfaceCount(route, raw.surfaces);
107
+ if (!surfaces.ok)
108
+ return surfaces;
109
+ const capsReason = capsKeysReason(route, raw.caps);
110
+ if (capsReason)
111
+ return invalid(capsReason);
112
+ // The v2 inert-field table does not list this one, but the terminal planner never reads it.
113
+ if (route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined) {
114
+ return invalid("route: terminal does not read `execution.timeoutMs`: `caps.maxMinutes` is the command deadline. Remove it.");
115
+ }
116
+ return {
117
+ ok: true,
118
+ value: { route, actor: raw.actor, participants: participants.value, surfaces: surfaces.value },
119
+ };
120
+ }
103
121
  /**
104
122
  * Spell a v2 parser message the way the study file is written: `actors[0].lanes[3]` becomes the
105
123
  * `participants` entry it came from, `execution.caps` and `scenario.caps` become `caps`, and so on.
@@ -0,0 +1,38 @@
1
+ import { type StudyConfig, type StudyParseFailure, type StudyScenarioCaps, type StudyV3 } from "../types.js";
2
+ interface StudyV3Options {
3
+ /**
4
+ * `report`, for `humanish migrate`: list the fields the route does not read in `inert`, as v2
5
+ * paths, in place of refusing the study for them, and give the checks' messages as they wrote
6
+ * them, in the v2 spelling of the file migrate converts. A shared world may then give
7
+ * `participants` as a count or leave it out, as a v2 file could (readStudyFront).
8
+ */
9
+ readonly inert?: "refuse" | "report";
10
+ }
11
+ type StudyV3ParseResult = {
12
+ ok: true;
13
+ config: StudyV3;
14
+ warnings: string[];
15
+ inert?: string[];
16
+ } | StudyParseFailure;
17
+ type Parsed<T> = {
18
+ ok: true;
19
+ value: T;
20
+ } | StudyParseFailure;
21
+ /**
22
+ * Validate a parsed humanish.study.v3 document into a StudyV3, with the messages parseStudy gives.
23
+ * Pure: the caller owns file IO. parseStudy refuses a humanish.lab.v2 document before this runs.
24
+ */
25
+ export declare function parseStudyV3(raw: unknown, options?: StudyV3Options): StudyV3ParseResult;
26
+ /**
27
+ * A v3 study in the v2 spelling: the actor holds its participants, `caps` moves under `execution`
28
+ * or `scenario`, and a shared world is `subject.topology: shared-world`. The checks that read more
29
+ * than one section run on this view until StudyConfig has the v3 shape.
30
+ */
31
+ export declare function toLegacy(study: StudyV3): StudyConfig;
32
+ /**
33
+ * A caps block's values. A value that is not a non-negative number refuses the study: a cap that
34
+ * does nothing must not look like one that holds. readStudyFront has checked the keys against the
35
+ * route; migrate checks a v2 file's two blocks with it.
36
+ */
37
+ export declare function parseCaps(raw: unknown): Parsed<StudyScenarioCaps | undefined>;
38
+ export {};