humanish 0.110.3 → 0.111.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 (115) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CONTEXT.md +2 -2
  3. package/dist/actors/local-agent/claude-session.js +1 -1
  4. package/dist/actors/local-agent/cli.js +1 -1
  5. package/dist/actors/scripted-browser/actor.d.ts +2 -2
  6. package/dist/actors/scripted-browser/actor.js +17 -8
  7. package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
  8. package/dist/actors/scripted-browser/launch-env.js +50 -0
  9. package/dist/analysis/automatic-completion.d.ts +2 -1
  10. package/dist/analysis/automatic-completion.js +7 -2
  11. package/dist/cli/commands/migrate.js +1 -1
  12. package/dist/cli/commands/runs.js +5 -2
  13. package/dist/cli/commands/study-route-computer-use.js +3 -2
  14. package/dist/cli/commands/study-route-preview.js +2 -1
  15. package/dist/cli/commands/study-route-shared-world.js +3 -3
  16. package/dist/cli/commands/study.js +2 -1
  17. package/dist/routes/computer-use/live-phase.js +8 -1
  18. package/dist/routes/computer-use/local-vm.js +2 -1
  19. package/dist/routes/computer-use/participant-model.js +1 -1
  20. package/dist/routes/computer-use/plan.js +9 -10
  21. package/dist/routes/computer-use/preflight.js +1 -1
  22. package/dist/routes/computer-use/result.js +1 -1
  23. package/dist/routes/computer-use/route.js +3 -2
  24. package/dist/routes/computer-use/setup.js +2 -1
  25. package/dist/routes/computer-use/types.d.ts +4 -4
  26. package/dist/routes/scripted/plan.js +5 -4
  27. package/dist/routes/scripted/route.js +6 -2
  28. package/dist/routes/scripted/scenario.d.ts +1 -1
  29. package/dist/routes/scripted/scenario.js +3 -3
  30. package/dist/routes/scripted/surface-sessions.d.ts +2 -0
  31. package/dist/routes/scripted/surface-sessions.js +2 -1
  32. package/dist/routes/scripted/types.d.ts +2 -2
  33. package/dist/routes/shared-world/handoff.js +1 -1
  34. package/dist/routes/shared-world/participant-specs.js +6 -2
  35. package/dist/routes/shared-world/plan.js +8 -8
  36. package/dist/routes/shared-world/route.js +3 -3
  37. package/dist/routes/shared-world/types.d.ts +1 -1
  38. package/dist/routes/terminal/bundle.d.ts +3 -3
  39. package/dist/routes/terminal/ledger.d.ts +2 -2
  40. package/dist/routes/terminal/lifetime.js +1 -1
  41. package/dist/routes/terminal/live-finish.js +1 -1
  42. package/dist/routes/terminal/plan.js +4 -3
  43. package/dist/routes/terminal/route.js +3 -2
  44. package/dist/routes/terminal/session.js +1 -0
  45. package/dist/routes/terminal/types.d.ts +1 -1
  46. package/dist/run/display.d.ts +10 -0
  47. package/dist/run/display.js +22 -0
  48. package/dist/run/reclaim.d.ts +4 -2
  49. package/dist/run/reclaim.js +61 -18
  50. package/dist/run/results.d.ts +1 -1
  51. package/dist/run/run.d.ts +11 -1
  52. package/dist/run/run.js +24 -5
  53. package/dist/run/status.d.ts +6 -0
  54. package/dist/run/status.js +1 -0
  55. package/dist/run/stored-runs.js +26 -11
  56. package/dist/run-study.d.ts +5 -3
  57. package/dist/run-study.js +7 -11
  58. package/dist/study/adapter-scorer-loader.d.ts +1 -1
  59. package/dist/study/adapter-scorer-loader.js +3 -3
  60. package/dist/study/composition-rules.js +49 -39
  61. package/dist/study/config.d.ts +31 -8
  62. package/dist/study/config.js +181 -155
  63. package/dist/study/discover.d.ts +1 -1
  64. package/dist/study/discover.js +1 -1
  65. package/dist/study/doctor.js +5 -4
  66. package/dist/study/keys.d.ts +250 -2
  67. package/dist/study/keys.js +32 -62
  68. package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
  69. package/dist/study/{convert.js → migrate/convert.js} +28 -31
  70. package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
  71. package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
  72. package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
  73. package/dist/study/migrate/v2.d.ts +78 -0
  74. package/dist/study/migrate/v2.js +607 -0
  75. package/dist/study/parse/actors.d.ts +34 -10
  76. package/dist/study/parse/actors.js +76 -174
  77. package/dist/study/parse/comms.js +6 -2
  78. package/dist/study/parse/execution.d.ts +9 -4
  79. package/dist/study/parse/execution.js +6 -41
  80. package/dist/study/parse/front.d.ts +36 -0
  81. package/dist/study/parse/{study-v3.js → front.js} +37 -88
  82. package/dist/study/parse/subject.d.ts +2 -0
  83. package/dist/study/parse/subject.js +15 -18
  84. package/dist/study/persona-resolve.d.ts +2 -4
  85. package/dist/study/persona-resolve.js +7 -8
  86. package/dist/study/plan-base.d.ts +1 -1
  87. package/dist/study/plan-base.js +6 -6
  88. package/dist/study/plan-participants.d.ts +2 -2
  89. package/dist/study/plan-participants.js +12 -11
  90. package/dist/study/plan-types.d.ts +5 -5
  91. package/dist/study/plan.js +5 -4
  92. package/dist/study/preflight-probes.js +4 -4
  93. package/dist/study/preflight.d.ts +1 -1
  94. package/dist/study/preflight.js +3 -3
  95. package/dist/study/routing.d.ts +5 -5
  96. package/dist/study/routing.js +17 -20
  97. package/dist/study/run-study-options.d.ts +1 -1
  98. package/dist/study/run-study-options.js +3 -3
  99. package/dist/study/study-deps.d.ts +1 -1
  100. package/dist/study/study-fields.d.ts +19 -0
  101. package/dist/study/study-fields.js +46 -0
  102. package/dist/study/summary.js +19 -18
  103. package/dist/study/types.d.ts +66 -104
  104. package/dist/study/types.js +0 -1
  105. package/dist/study/url-credentials.js +3 -3
  106. package/dist/study/validation.d.ts +4 -10
  107. package/dist/study/validation.js +54 -79
  108. package/dist/study/warnings.d.ts +3 -3
  109. package/dist/study/warnings.js +42 -110
  110. package/dist/substrates/local/runtime-config.js +14 -16
  111. package/dist/tui-app.js +3 -3
  112. package/docs/contracts/schemas.md +36 -8
  113. package/package.json +1 -1
  114. package/dist/study/parse/study-v3.d.ts +0 -21
  115. /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
@@ -1,10 +1,8 @@
1
- // humanish.study.v3, the study format. A study declares its route, one `actor`, its `participants`
2
- // and one `caps` block. studyToV2 rewrites a v3 document into the v2 spelling, so both formats run
3
- // through one parser and parse into the same StudyConfig. parseStudy then checks the declared
4
- // route against the one the config takes, and refuses any field that route does not read.
1
+ // What a humanish.study.v3 document declares before its sections parse: its route, one `actor`,
2
+ // its `participants` or `surfaces`, and the `caps` keys the route reads. config.ts parses the
3
+ // sections after these checks pass.
5
4
  import { isRecord } from "../../run/type-guards.js";
6
5
  import { findUnknownStudyKey } from "../keys.js";
7
- import { V2_SCHEMA } from "../types.js";
8
6
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
9
7
  import { invalid, posInt } from "./values.js";
10
8
  // The values `route:` takes: every route routeOf can return.
@@ -24,8 +22,23 @@ const CAPS_KEYS = {
24
22
  terminal: ["maxUsd", "maxJobs", "maxMinutes"],
25
23
  scripted: [],
26
24
  };
27
- /** Rewrite a humanish.study.v3 document into humanish.lab.v2, or refuse it. */
28
- export function studyToV2(raw) {
25
+ /** The refusal for a study with no `actor` object. */
26
+ export const ACTOR_REQUIRED = "A study needs `actor:`, an object with at least `type`.";
27
+ /** Why `route` is not a route a study can declare, or undefined when it is one. */
28
+ export function routeNameReason(route) {
29
+ return ROUTES.some((name) => name === route)
30
+ ? undefined
31
+ : `A study needs \`route:\`, one of ${ROUTES.join(", ")}.`;
32
+ }
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 function readStudyFront(raw, anySharedWorldParticipants = false) {
29
42
  const moved = movedKeyReason(raw);
30
43
  if (moved)
31
44
  return invalid(moved);
@@ -33,90 +46,33 @@ export function studyToV2(raw) {
33
46
  if (unknownKey)
34
47
  return invalid(unknownKey);
35
48
  const route = ROUTES.find((name) => name === raw.route);
36
- if (route === undefined) {
37
- return invalid(`A study needs \`route:\`, one of ${ROUTES.join(", ")}.`);
38
- }
49
+ if (route === undefined)
50
+ return invalid(routeNameReason(raw.route));
39
51
  if (raw.mode !== undefined && raw.mode !== "dry-run" && raw.mode !== "live") {
40
52
  return invalid("`mode` must be `dry-run` or `live`. Leave it out for a dry run.");
41
53
  }
42
- if (!isRecord(raw.actor)) {
43
- return invalid("A study needs `actor:`, an object with at least `type`.");
44
- }
54
+ if (!isRecord(raw.actor))
55
+ return invalid(ACTOR_REQUIRED);
45
56
  if (raw.scenario !== undefined && typeof raw.scenario !== "string") {
46
57
  return invalid("`scenario` is a scenario id or a path to one, as a string.");
47
58
  }
48
- const participants = participantsOf(route, raw.participants);
59
+ const participants = participantsOf(anySharedWorldParticipants && route === "shared-world" ? "computer-use" : route, raw.participants);
49
60
  if (!participants.ok)
50
61
  return participants;
51
- const surfaces = surfaceCount(route, raw.surfaces);
62
+ const surfaces = surfacesOf(route, raw.surfaces);
52
63
  if (!surfaces.ok)
53
64
  return surfaces;
54
65
  const capsReason = capsKeysReason(route, raw.caps);
55
66
  if (capsReason)
56
67
  return invalid(capsReason);
57
- // The v2 inert-field table does not list this one, but the terminal planner never reads it.
68
+ // The inert-field rows (warnings.ts) do not list this one; the terminal planner never reads it.
58
69
  if (route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined) {
59
70
  return invalid("route: terminal does not read `execution.timeoutMs`: `caps.maxMinutes` is the command deadline. Remove it.");
60
71
  }
61
- const { count, instruction, entries, source } = participants.value;
62
- // The v2 actor keys that hold participants.
63
- const v2Participants = [
64
- ["count", count ?? surfaces.value],
65
- ["laneFocus", instruction === undefined ? undefined : { instruction }],
66
- ["lanes", entries],
67
- ];
68
- const actor = { ...raw.actor };
69
- for (const [key, value] of v2Participants)
70
- if (value !== undefined)
71
- actor[key] = value;
72
- const caps = raw.caps;
73
- const scenario = {};
74
- if (raw.scenario !== undefined)
75
- scenario.ref = raw.scenario;
76
- if (raw.mode !== undefined)
77
- scenario.mode = raw.mode;
78
- if (caps !== undefined && route === "terminal")
79
- scenario.caps = caps;
80
- const execution = caps !== undefined && route !== "terminal"
81
- ? withKey(raw.execution, "caps", caps)
82
- : raw.execution;
83
- const subject = route === "shared-world" ? withKey(raw.subject, "topology", "shared-world") : raw.subject;
84
- const v2 = { schema: V2_SCHEMA };
85
- const fields = [
86
- ["id", raw.id],
87
- ["title", raw.title],
88
- ["description", raw.description],
89
- ["subject", subject],
90
- ["actors", [actor]],
91
- ["execution", execution],
92
- ["scenario", Object.keys(scenario).length > 0 ? scenario : undefined],
93
- ["policies", raw.policies],
94
- ["review", raw.review],
95
- ["defaults", raw.defaults],
96
- ["comms", raw.comms],
97
- ];
98
- for (const [key, value] of fields)
99
- if (value !== undefined)
100
- v2[key] = value;
101
- return { ok: true, value: { route, v2, participantSource: source } };
102
- }
103
- /**
104
- * Spell a v2 parser message the way the study file is written: `actors[0].lanes[3]` becomes the
105
- * `participants` entry it came from, `execution.caps` and `scenario.caps` become `caps`, and so on.
106
- */
107
- export function studySpelling(message, route, participantSource) {
108
- return (message
109
- .replaceAll(/actors\[0\]\.lanes\[(\d+)\]/g, (_match, index) => `participants[${participantSource[Number(index)] ?? index}]`)
110
- // "an `actors[0].lanes` roster" reads "a `participants` list" in a v3 file.
111
- .replaceAll(/\ban `actors\[0\]\.(lanes|roster)` roster\b/g, "a `participants` list")
112
- .replaceAll(/actors\[0\]\.(lanes|roster)\b/g, "participants")
113
- .replaceAll(/actors\[0\]\.laneFocus(\.instruction)?/g, "participants.instruction")
114
- .replaceAll(/actors\[0\]\.count\b/g, route === "scripted" ? "surfaces" : "participants")
115
- .replaceAll("actors[0]", "actor")
116
- .replaceAll(/\b(execution|scenario)\.caps\b/g, "caps")
117
- .replaceAll(/\bscenario\.mode\b/g, "mode")
118
- .replaceAll(/\bscenario\.ref\b/g, "scenario")
119
- .replaceAll("subject.topology: shared-world", "route: shared-world"));
72
+ return {
73
+ ok: true,
74
+ value: { route, actor: raw.actor, participants: participants.value, surfaces: surfaces.value },
75
+ };
120
76
  }
121
77
  // The v2 keys a hand-converted file is most likely to keep, each with where it went.
122
78
  function movedKeyReason(raw) {
@@ -194,7 +150,7 @@ function homogeneousOf(raw) {
194
150
  return { ok: true, value: participants };
195
151
  }
196
152
  // A list entry with `count: n` is a group: n participants `<id>-01` to `<id>-NN`, even when n is 1.
197
- // The v2 parser then checks every entry and the expanded ids for collisions.
153
+ // config.ts then checks every entry and the expanded ids for collisions.
198
154
  function entriesOf(raw) {
199
155
  const entries = [];
200
156
  const source = [];
@@ -223,17 +179,17 @@ function entriesOf(raw) {
223
179
  }
224
180
  return { ok: true, value: { entries, source } };
225
181
  }
226
- // Scripted surfaces become the v2 count: 1 is desktop, 2 adds mobile.
227
- function surfaceCount(route, raw) {
182
+ function surfacesOf(route, raw) {
228
183
  if (raw === undefined)
229
184
  return { ok: true, value: undefined };
230
185
  if (route !== "scripted") {
231
186
  return invalid(`\`surfaces\` belongs to route: scripted. This study's route is ${route}; remove it.`);
232
187
  }
233
- if (Array.isArray(raw) && raw.length === 1 && raw[0] === "desktop")
234
- return { ok: true, value: 1 };
188
+ if (Array.isArray(raw) && raw.length === 1 && raw[0] === "desktop") {
189
+ return { ok: true, value: ["desktop"] };
190
+ }
235
191
  if (Array.isArray(raw) && raw.length === 2 && raw[0] === "desktop" && raw[1] === "mobile") {
236
- return { ok: true, value: 2 };
192
+ return { ok: true, value: ["desktop", "mobile"] };
237
193
  }
238
194
  return invalid("`surfaces` must be [desktop] or [desktop, mobile].");
239
195
  }
@@ -250,10 +206,3 @@ function capsKeysReason(route, raw) {
250
206
  return undefined;
251
207
  return `route: ${route} reads only ${read.map((key) => `caps.${key}`).join(", ")}. Remove ${unread.map((key) => `caps.${key}`).join(", ")}.`;
252
208
  }
253
- // Add one key to a section. A section that is not an object passes through unchanged, so the v2
254
- // parser refuses it with its own message.
255
- function withKey(section, key, value) {
256
- if (section === undefined)
257
- return { [key]: value };
258
- return isRecord(section) ? { ...section, [key]: value } : section;
259
- }
@@ -1,4 +1,6 @@
1
1
  import type { StudyParseFailure, StudySubject } from "../types.js";
2
+ /** True when `raw` is a subject mapping whose `source` parseSubject accepts. */
3
+ export declare function hasSubjectSource(raw: unknown): boolean;
2
4
  export declare function parseSubject(raw: unknown): {
3
5
  ok: true;
4
6
  value: StudySubject;
@@ -3,31 +3,28 @@ import { ENV_NAME_PATTERN, invalid, posInt, str, strList } from "./values.js";
3
3
  import { parseEnvValues, parseState, subjectStateInvalidReason } from "./subject-state.js";
4
4
  import { isRecord } from "../../run/type-guards.js";
5
5
  import { urlCredentialReason } from "./url-credentials.js";
6
+ const SOURCES = [
7
+ "this-repo",
8
+ "clone",
9
+ "app-url",
10
+ "local-app",
11
+ "desktop-cli",
12
+ "terminal-product",
13
+ "local-tree",
14
+ ];
15
+ /** True when `raw` is a subject mapping whose `source` parseSubject accepts. */
16
+ export function hasSubjectSource(raw) {
17
+ return isRecord(raw) && SOURCES.some((name) => name === str(raw.source));
18
+ }
6
19
  export function parseSubject(raw) {
7
20
  if (!isRecord(raw)) {
8
21
  return invalid("`subject` is required and must be an object.");
9
22
  }
10
- const source = str(raw.source);
11
- if (source !== "this-repo" &&
12
- source !== "clone" &&
13
- source !== "app-url" &&
14
- source !== "local-app" &&
15
- source !== "desktop-cli" &&
16
- source !== "terminal-product" &&
17
- source !== "local-tree") {
23
+ const source = SOURCES.find((name) => name === str(raw.source));
24
+ if (source === undefined) {
18
25
  return invalid("`subject.source` must be one of: this-repo, clone, app-url, local-app, terminal-product, desktop-cli, local-tree.");
19
26
  }
20
27
  const subject = { source };
21
- // topology is enum-validated everywhere; its semantics (shared-world requires clone × e2b-desktop
22
- // × a ≥2 roster) are enforced in the shared-world cross-validation below, and a set-but-unconsumed
23
- // topology warns as inert off the shared-world route.
24
- if (raw.topology !== undefined) {
25
- const topology = str(raw.topology);
26
- if (topology !== "per-lane-worlds" && topology !== "shared-world") {
27
- return invalid("`subject.topology` must be per-lane-worlds (the default) or shared-world.");
28
- }
29
- subject.topology = topology;
30
- }
31
28
  // exposure is enum-validated everywhere; it is required on the concurrent shared-world route (the
32
29
  // getHost synthetic-subject attestation) and warns inert elsewhere.
33
30
  if (raw.exposure !== undefined) {
@@ -1,6 +1,6 @@
1
1
  import { type ResolvedPersona } from "./persona.js";
2
2
  import { type PreparedSelectedOutputDirectory } from "../run/contained-output.js";
3
- import type { StudyActor } from "./types.js";
3
+ import type { StudyConfig } from "./types.js";
4
4
  /** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
5
5
  export declare function personaTitleFromId(personaId: string): string;
6
6
  /**
@@ -25,9 +25,7 @@ export declare function resolveCommittedPersonas(projectRoot: PreparedSelectedOu
25
25
  * Every persona id a study config could put on a browser participant: the roster when one is
26
26
  * declared, otherwise the actor-level persona that every fan-out participant inherits.
27
27
  */
28
- export declare function studyPersonaIds(config: {
29
- actors?: readonly Pick<StudyActor, "persona" | "lanes">[];
30
- }): string[];
28
+ export declare function studyPersonaIds(config: StudyConfig): string[];
31
29
  /**
32
30
  * Same resolution from a plain cwd, for the labs that carry a directory string rather than an
33
31
  * already-prepared root. Realpath-then-prepare mirrors the computer-use route so a symlinked cwd
@@ -5,7 +5,7 @@ import { parseResolvedPersona, PersonaConfigError } from "./persona.js";
5
5
  import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
6
6
  import { digestText, redactText } from "../evidence/redaction.js";
7
7
  import { realpath } from "node:fs/promises";
8
- import { rosterOf } from "./parse/actors.js";
8
+ import { actorOf, participantList } from "./study-fields.js";
9
9
  /** Persona ids are file-name segments, never paths: the same grammar the terminal route enforces. */
10
10
  const PERSONA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
11
11
  /** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
@@ -86,13 +86,12 @@ export async function resolveCommittedPersonas(projectRoot, personaIds) {
86
86
  */
87
87
  export function studyPersonaIds(config) {
88
88
  const ids = [];
89
- for (const actor of config.actors ?? []) {
90
- if (actor.persona)
91
- ids.push(actor.persona);
92
- for (const entry of rosterOf(actor) ?? []) {
93
- if (entry.persona)
94
- ids.push(entry.persona);
95
- }
89
+ const persona = actorOf(config)?.persona;
90
+ if (persona)
91
+ ids.push(persona);
92
+ for (const entry of participantList(config) ?? []) {
93
+ if (entry.persona)
94
+ ids.push(entry.persona);
96
95
  }
97
96
  return [...new Set(ids)];
98
97
  }
@@ -2,7 +2,7 @@ import type { Brain, ComputerUsePlan, NonEmpty, PlannedAnalysis, ProvisionedSubj
2
2
  import type { StudyConfig } from "./types.js";
3
3
  export type Base = Omit<ComputerUsePlan, "route" | "actor" | "runner" | "concurrency" | "sessionBudgetMs" | "sandboxMs" | "caps" | "rerun">;
4
4
  export declare function isNonEmpty<T>(values: readonly T[]): values is NonEmpty<T>;
5
- export declare function capsOf(config: StudyConfig): ComputerUsePlan["caps"];
5
+ export declare function planCaps(config: StudyConfig): ComputerUsePlan["caps"];
6
6
  export declare function provisionedSubject(config: StudyConfig): ProvisionedSubject | undefined;
7
7
  /** Keys, env and local tools a live run checks right before it acquires anything. */
8
8
  export declare function desktopRequirements(config: StudyConfig, args: {
@@ -1,6 +1,7 @@
1
1
  // What every route planner shares: the plan fields common to all routes, the frozen residual config,
2
2
  // and the requirement and subject helpers.
3
3
  import { DEFAULT_OPENAI_CU_MODEL } from "../actors/computer-use/openai-provider.js";
4
+ import { actorOf, capsOf } from "./study-fields.js";
4
5
  // A YAML alias can make a parsed record contain itself (an inline persona that names its own
5
6
  // anchor), and structuredClone keeps the cycle, so each object is frozen once.
6
7
  function deepFreeze(value, frozen = new WeakSet()) {
@@ -13,11 +14,10 @@ function deepFreeze(value, frozen = new WeakSet()) {
13
14
  return value;
14
15
  }
15
16
  function residualOf(config) {
16
- const { comms, policies, personas, defaults, review } = config;
17
+ const { comms, policies, defaults, review } = config;
17
18
  return deepFreeze(structuredClone({
18
19
  ...(comms === undefined ? {} : { comms }),
19
20
  ...(policies === undefined ? {} : { policies }),
20
- ...(personas === undefined ? {} : { personas }),
21
21
  ...(defaults === undefined ? {} : { defaults }),
22
22
  ...(review === undefined ? {} : { review }),
23
23
  ...(config.execution?.desktop === undefined && config.execution?.target === undefined
@@ -43,8 +43,8 @@ function residualOf(config) {
43
43
  export function isNonEmpty(values) {
44
44
  return values.length > 0;
45
45
  }
46
- export function capsOf(config) {
47
- const caps = config.execution?.caps;
46
+ export function planCaps(config) {
47
+ const caps = capsOf(config);
48
48
  return {
49
49
  ...(caps?.maxUsd === undefined ? {} : { maxUsd: caps.maxUsd }),
50
50
  ...(caps?.maxTotalUsd === undefined ? {} : { maxTotalUsd: caps.maxTotalUsd }),
@@ -86,7 +86,7 @@ export function desktopRequirements(config, args) {
86
86
  return requirements;
87
87
  }
88
88
  export function brainOf(config, callerProvider) {
89
- const actor = config.actors[0];
89
+ const actor = actorOf(config);
90
90
  if (callerProvider)
91
91
  return callerBrainOf(config);
92
92
  const declared = declaredModelOf(config);
@@ -99,7 +99,7 @@ export function callerBrainOf(config) {
99
99
  return { kind: "caller", ...declaredModelOf(config) };
100
100
  }
101
101
  function declaredModelOf(config) {
102
- const model = config.actors[0]?.model;
102
+ const model = actorOf(config)?.model;
103
103
  return model === undefined ? {} : { declaredModel: model };
104
104
  }
105
105
  /** The model a participant's spend is priced at when its provider reports none. */
@@ -9,7 +9,7 @@ export interface Participant {
9
9
  readonly id: string;
10
10
  /** 0-based position in the roster. Bundle `sim-NNN` and `stream-NNN` ids derive from it. */
11
11
  readonly index: number;
12
- /** `lanes[i].persona ?? actors[0].persona`. The route compiles the committed persona file. */
12
+ /** `participants[i].persona ?? actor.persona`. The route compiles the committed persona file. */
13
13
  readonly personaId: string | undefined;
14
14
  /** The declared mission and per-participant focus. An absent mission takes the route's default. */
15
15
  readonly assignment: {
@@ -30,7 +30,7 @@ interface DesktopParticipant extends Participant {
30
30
  readonly preset: DevicePreset;
31
31
  readonly resolution: [number, number];
32
32
  };
33
- /** The `lanes[]` entry's value, else actor value, else absent (the provider default is recorded in the trace). */
33
+ /** The `participants` entry's value, else actor value, else absent (the provider default is recorded in the trace). */
34
34
  readonly limits: {
35
35
  readonly stopWhen?: StopWhen;
36
36
  readonly dwell?: DwellWindow;
@@ -2,9 +2,9 @@
2
2
  // Computer-use and shared-world participants share the desktop fields; each kind adds its own.
3
3
  import { resolveParticipantDevice } from "./device-presets.js";
4
4
  import { isSharedWorldComposition, participantIdAt } from "./routing.js";
5
- import { focusOf, rosterOf } from "./parse/actors.js";
5
+ import { actorOf, participantList, declaredParticipantCount, participantInstruction, } from "./study-fields.js";
6
6
  function desktopParticipant(config, entry, index, kind, focus) {
7
- const actor = config.actors[0];
7
+ const actor = actorOf(config);
8
8
  const device = resolveParticipantDevice(config, entry?.device);
9
9
  const personaId = entry?.persona ?? actor?.persona;
10
10
  const mission = actor?.mission;
@@ -39,12 +39,14 @@ function desktopParticipant(config, entry, index, kind, focus) {
39
39
  * override (`--count`) applies only when no roster is declared.
40
40
  */
41
41
  export function computerUseParticipants(config, countOverride) {
42
- const actor = config.actors[0];
43
- const roster = rosterOf(actor);
44
- const count = roster ? roster.length : Math.max(1, countOverride ?? actor?.count ?? 1);
42
+ const actor = actorOf(config);
43
+ const roster = participantList(config);
44
+ const count = roster
45
+ ? roster.length
46
+ : Math.max(1, countOverride ?? declaredParticipantCount(config) ?? 1);
45
47
  return Array.from({ length: count }, (_, index) => {
46
48
  const entry = roster?.[index];
47
- const focus = roster ? entry?.instruction : focusOf(actor)?.instruction;
49
+ const focus = roster ? entry?.instruction : participantInstruction(config);
48
50
  return {
49
51
  ...desktopParticipant(config, entry, index, "lane", focus),
50
52
  ...(actor?.tasks === undefined ? {} : { tasks: actor.tasks }),
@@ -54,7 +56,7 @@ export function computerUseParticipants(config, countOverride) {
54
56
  }
55
57
  /** The participants of a shared-world study, one per roster entry, typed by the plane they share. */
56
58
  export function sharedWorldParticipants(config) {
57
- const roster = rosterOf(config.actors[0]) ?? [];
59
+ const roster = participantList(config) ?? [];
58
60
  const participantAt = (entry, index) => desktopParticipant(config, entry, index, "seat", entry.instruction);
59
61
  if (config.subject.source === "app-url") {
60
62
  return {
@@ -75,18 +77,17 @@ export function sharedWorldParticipants(config) {
75
77
  }
76
78
  /** The ids of the participants a computer-use or shared-world study runs, in roster order. */
77
79
  export function declaredParticipantIds(config) {
78
- const actor = config.actors[0];
79
- const roster = rosterOf(actor);
80
+ const roster = participantList(config);
80
81
  const kind = isSharedWorldComposition(config) ? "seat" : "lane";
81
82
  if (roster && roster.length > 0) {
82
83
  return roster.map((entry, index) => participantIdAt(index, entry.id, kind));
83
84
  }
84
- const count = Math.max(1, actor?.count ?? 1);
85
+ const count = Math.max(1, declaredParticipantCount(config) ?? 1);
85
86
  return Array.from({ length: count }, (_, index) => participantIdAt(index, undefined, kind));
86
87
  }
87
88
  /** The entry URLs roster entries declare in place of the subject URL (computer use, app-url). */
88
89
  export function declaredTargets(config) {
89
- return (rosterOf(config.actors[0]) ?? [])
90
+ return (participantList(config) ?? [])
90
91
  .map((entry) => entry.target)
91
92
  .filter((target) => target !== undefined);
92
93
  }
@@ -8,7 +8,7 @@ import type { RunStudyProvenance } from "../run/study-provenance.js";
8
8
  import type { ComputerUseRefusal } from "../routes/computer-use/plan.js";
9
9
  import type { SharedWorldRefusal } from "../routes/shared-world/plan.js";
10
10
  import type { ComputerUseParticipant, ExternalPublicParticipant, ProvisionedParticipant } from "./plan-participants.js";
11
- import type { StudyConfig, StudyExecutionTerminal, StudyRuntimeAuth, StudyScenarioCaps, StudySubjectProduct, StudySubjectServe, StudySubjectState, StudySubjectStateCheckpoint } from "./types.js";
11
+ import type { StudyConfig, StudyExecutionTerminal, StudyRuntimeAuth, StudyCaps, StudySubjectProduct, StudySubjectServe, StudySubjectState, StudySubjectStateCheckpoint } from "./types.js";
12
12
  export type NonEmpty<T> = readonly [T, ...T[]];
13
13
  type AtLeastTwo<T> = readonly [T, T, ...T[]];
14
14
  /**
@@ -48,7 +48,7 @@ export type Requirement = {
48
48
  * VM study read it before a plan exists. Shared world, terminal and scripted still read some config
49
49
  * fields at run time.
50
50
  */
51
- export type ResidualConfig = Pick<StudyConfig, "comms" | "policies" | "personas" | "defaults" | "review"> & {
51
+ export type ResidualConfig = Pick<StudyConfig, "comms" | "policies" | "defaults" | "review"> & {
52
52
  readonly execution?: Pick<NonNullable<StudyConfig["execution"]>, "desktop" | "target">;
53
53
  readonly subject: Pick<StudyConfig["subject"], "clone" | "localTree" | "repos" | "envValues">;
54
54
  };
@@ -83,7 +83,7 @@ interface PreviewPlan extends PlanBase {
83
83
  }
84
84
  /**
85
85
  * The model driving a desktop participant. `caller` is the library caller's createProvider.
86
- * `declaredModel` is actors[0].model as the study wrote it, absent when undeclared: the providers
86
+ * `declaredModel` is actor.model as the study wrote it, absent when undeclared: the providers
87
87
  * take it as written, and spend is priced at it, else the default (pricedModel). An openai
88
88
  * brain's `model` is the one its provider runs, with that default applied.
89
89
  */
@@ -229,10 +229,10 @@ export type TerminalPlan = PlanBase & {
229
229
  readonly stdin?: NonNullable<StudyExecutionTerminal["stdin"]>;
230
230
  } & ({
231
231
  readonly dryRun: true;
232
- readonly caps?: StudyScenarioCaps;
232
+ readonly caps?: StudyCaps;
233
233
  } | {
234
234
  readonly dryRun: false;
235
- readonly caps: StudyScenarioCaps & {
235
+ readonly caps: StudyCaps & {
236
236
  readonly maxUsd: number;
237
237
  readonly maxMinutes: number;
238
238
  };
@@ -1,5 +1,5 @@
1
1
  // The route decision. A study's route follows from its composition (subject.source,
2
- // execution.target, the first actor's registered run kind, subject.topology), never from a
2
+ // execution.target, the actor's registered run kind, `route: shared-world`), never from a
3
3
  // declared kind. This is the only function that decides it.
4
4
  import { resolveAutomaticAnalysis } from "../analysis/automatic-config.js";
5
5
  import { callerDrivingOf, planComputerUseStudy } from "../routes/computer-use/plan.js";
@@ -11,16 +11,17 @@ import { THIS_REPO_DRY_RUN_ONLY } from "./composition-rules.js";
11
11
  import { planBase } from "./plan-base.js";
12
12
  import { routeOf } from "./routing.js";
13
13
  import { automaticAnalysisRouteReason, taskProtocolValidationReason } from "./validation.js";
14
+ import { declaredParticipantCount, modeOf } from "./study-fields.js";
14
15
  export { routeOf } from "./routing.js";
15
16
  /** Resolve dry-run: explicit override wins, else the scenario mode, else the given fallback. */
16
17
  export function resolveStudyDryRun(config, override, fallback) {
17
18
  if (override !== undefined) {
18
19
  return override;
19
20
  }
20
- if (config.scenario?.mode === "live") {
21
+ if (modeOf(config) === "live") {
21
22
  return false;
22
23
  }
23
- if (config.scenario?.mode === "dry-run") {
24
+ if (modeOf(config) === "dry-run") {
24
25
  return true;
25
26
  }
26
27
  return fallback;
@@ -45,7 +46,7 @@ function planPreview(config, options, input) {
45
46
  const tasksReason = taskProtocolValidationReason(config);
46
47
  if (tasksReason)
47
48
  return refuse("HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason);
48
- const participantCount = options.count ?? config.actors[0]?.count ?? 4;
49
+ const participantCount = options.count ?? declaredParticipantCount(config) ?? 4;
49
50
  if (!Number.isSafeInteger(participantCount) || participantCount < 1)
50
51
  return refuse("HUMANISH_INVALID_PARTICIPANT_COUNT", "count must be a positive integer.");
51
52
  if (!input.dryRun)
@@ -12,9 +12,9 @@ import { acquireE2BDesktopSandbox, readE2BRelease } from "../substrates/e2b/sand
12
12
  import { e2bShell } from "../substrates/e2b/shell.js";
13
13
  import { redactText } from "../evidence/redaction.js";
14
14
  import { digest, fail, finalize } from "./preflight-result.js";
15
- import { rosterOf } from "./parse/actors.js";
16
15
  import { plural } from "../run/text.js";
17
16
  import { cli } from "../cli/invocation.js";
17
+ import { participantList } from "./study-fields.js";
18
18
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
19
19
  // Room on the probe's lease for desktop boot and teardown around the work it does.
20
20
  const PREFLIGHT_LEASE_BUFFER_MS = 5 * 60_000;
@@ -25,7 +25,7 @@ export async function runPublicPreviewPreflight(ctx) {
25
25
  { name: "route", ok: false, message: routeError },
26
26
  ]);
27
27
  }
28
- const rosterTargets = ctx.targets.filter((target) => target.kind === "actors[0].lanes[].target");
28
+ const rosterTargets = ctx.targets.filter((target) => target.kind === "participants[].target");
29
29
  const publicTargets = rosterTargets.length > 0
30
30
  ? rosterTargets
31
31
  : ctx.targets.filter((target) => target.kind === "subject.appUrl");
@@ -270,8 +270,8 @@ function targetUrlFor(config, target) {
270
270
  if (target.kind === "subject.appUrl" && config.subject.appUrl) {
271
271
  return config.subject.appUrl;
272
272
  }
273
- if (target.kind === "actors[0].lanes[].target") {
274
- const rosterTarget = rosterOf(config.actors[0])?.find((entry) => entry.target && digest(entry.target) === target.targetDigest)?.target;
273
+ if (target.kind === "participants[].target") {
274
+ const rosterTarget = participantList(config)?.find((entry) => entry.target && digest(entry.target) === target.targetDigest)?.target;
275
275
  if (rosterTarget)
276
276
  return rosterTarget;
277
277
  }
@@ -12,7 +12,7 @@ export interface StudyPreflightCheck {
12
12
  }
13
13
  export interface StudyPreflightTarget {
14
14
  label: string;
15
- kind: "subject.appUrl" | "actors[0].lanes[].target" | "subject.serve.url" | "subject.product.publicSurface";
15
+ kind: "subject.appUrl" | "participants[].target" | "subject.serve.url" | "subject.product.publicSurface";
16
16
  targetDigest: string;
17
17
  originDigest?: string;
18
18
  loopback: boolean;
@@ -5,7 +5,7 @@ import { runPublicPreviewPreflight, runSandboxLoopbackPreflight } from "./prefli
5
5
  import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
6
6
  import { routeOf } from "./plan.js";
7
7
  import { resolveStudyManifest } from "./discover.js";
8
- import { rosterOf } from "./parse/actors.js";
8
+ import { participantList } from "./study-fields.js";
9
9
  const DEFAULT_PREFLIGHT_TIMEOUT_MS = 30_000;
10
10
  export async function runStudyPreflight(options) {
11
11
  const cwd = path.resolve(options.cwd);
@@ -82,9 +82,9 @@ function collectTargets(config) {
82
82
  if (config.subject.appUrl) {
83
83
  targets.push(makeTarget("subject.appUrl", "subject.appUrl", config.subject.appUrl));
84
84
  }
85
- for (const [index, entry] of (rosterOf(config.actors[0]) ?? []).entries()) {
85
+ for (const [index, entry] of (participantList(config) ?? []).entries()) {
86
86
  if (entry.target) {
87
- targets.push(makeTarget(`actors[0].lanes[${index}].target`, "actors[0].lanes[].target", entry.target));
87
+ targets.push(makeTarget(`participants[${index}].target`, "participants[].target", entry.target));
88
88
  }
89
89
  }
90
90
  if (config.subject.serve?.url) {
@@ -9,8 +9,8 @@ export declare function registeredScriptedBrowserActors(): string[];
9
9
  export declare function actorResolvesToTerminal(type: string | undefined): boolean;
10
10
  export declare function registeredTerminalActors(): string[];
11
11
  /**
12
- * The declared fan-out participant count on the computer-use route: a `lanes[]` roster's length, else
13
- * a homogeneous `count`, else 1. The single source of truth shared by the parser, the engine,
12
+ * The declared fan-out participant count on the computer-use route: a `participants` list's
13
+ * length, else a participant count, else 1. The single source of truth shared by the parser, the engine,
14
14
  * and the pre-flight plan so the participant count is computed the same way everywhere.
15
15
  */
16
16
  export declare function computerUseParticipantCount(config: StudyConfig): number;
@@ -36,15 +36,15 @@ export declare function isComputerUseComposition(config: StudyConfig): boolean;
36
36
  * `shared-world` topology. Mirror of isComputerUseComposition; the single source of truth shared by
37
37
  * routeOf (which checks it before the computer-use route) and the warning logic. Every
38
38
  * shared-world study runs its participants at once (`execution.concurrency` >= 2). The same
39
- * clone/local-tree × e2b-desktop × computer-use composition without `topology: shared-world` stays per-lane-worlds
40
- * (the computer-use route); the topology declaration is the override switch.
39
+ * clone/local-tree × e2b-desktop × computer-use composition without `route: shared-world` stays per-lane-worlds
40
+ * (the computer-use route); the route declaration is the override switch.
41
41
  */
42
42
  export declare function isSharedWorldComposition(config: StudyConfig): boolean;
43
43
  /** The getHost provisioned-subject shared-world shape (clone/local-tree served + exposed in-sandbox). */
44
44
  export declare function isProvisionedSharedWorldComposition(config: StudyConfig): boolean;
45
45
  /**
46
46
  * The external-public shared-world shape: a real public deployment used directly as
47
- * the shared plane: `source: app-url` + `topology: shared-world` + a computer-use actor on
47
+ * the shared plane: `source: app-url` + `route: shared-world` + a computer-use actor on
48
48
  * e2b-desktop + `policies.allowPublicTargets: true`. No getHost, no clone, no subject sandbox, no
49
49
  * seed. The operator-ownership attestation `subject.publicTarget` is required (validated in
50
50
  * externalPublicSharedWorldValidationReason, not here; this predicate is the router only, so a