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,225 @@
1
+ // humanish.study.v3, parsed into a config with the file's own keys (StudyV3). parseStudy still
2
+ // rewrites a v3 file into the v2 spelling and parses that (parse/study-v3.ts).
3
+ // tests/study/parse-differential.test.ts holds the two to the same result. The checks that read
4
+ // more than one section still take the v2 shape, so they run on toLegacy's view of the study.
5
+ import { isRecord } from "../../run/type-guards.js";
6
+ import { checkStudyConfig } from "../config.js";
7
+ import { routeOf } from "../routing.js";
8
+ import { ID_PATTERN, STUDY_SCHEMA, V2_SCHEMA, } from "../types.js";
9
+ import { inertFieldLabels, inertFieldPaths } from "../warnings.js";
10
+ import { parseActorFields, parseActorType, parseParticipantEntries, withoutParticipants, withParticipants, } from "./actors.js";
11
+ import { parseComms } from "./comms.js";
12
+ import { parseDefaults, parseExecution, parsePolicies, parseReview } from "./execution.js";
13
+ import { readStudyFront, studySpelling } from "./study-v3.js";
14
+ import { parseSubject } from "./subject.js";
15
+ import { invalid, nonNegNumber, optionalStr, str } from "./values.js";
16
+ /**
17
+ * Validate a parsed humanish.study.v3 document into a StudyV3, with the messages parseStudy gives.
18
+ * Pure: the caller owns file IO. parseStudy refuses a humanish.lab.v2 document before this runs.
19
+ */
20
+ export function parseStudyV3(raw, options = {}) {
21
+ if (!isRecord(raw))
22
+ return invalid("A study file must be a YAML object.");
23
+ if (raw.schema !== STUDY_SCHEMA) {
24
+ return invalid(`The study schema must be ${STUDY_SCHEMA} or ${V2_SCHEMA}.`);
25
+ }
26
+ const front = readStudyFront(raw, options.inert === "report");
27
+ if (!front.ok)
28
+ return front;
29
+ const study = parseSections(raw, front.value);
30
+ if (!study.ok)
31
+ return study;
32
+ const spell = (message) => options.inert === "report"
33
+ ? message
34
+ : studySpelling(message, front.value.route, front.value.participants.source);
35
+ const checked = checkStudyConfig(toLegacy(study.value));
36
+ if (!checked.ok)
37
+ return invalid(spell(checked.error.message));
38
+ const { route } = study.value;
39
+ const taken = routeOf(checked.config);
40
+ if (taken !== route) {
41
+ return invalid(`This study declares route: ${route}, but its subject (source: ${checked.config.subject.source}) and actor (type: ${checked.config.actors[0]?.type}) take the ${taken} route. Change \`route\`, or change the subject or actor.`);
42
+ }
43
+ const config = withFilledDefaults(study.value, checked.config);
44
+ const warnings = checked.warnings.map(spell);
45
+ if (options.inert === "report") {
46
+ return { ok: true, config, warnings, inert: inertFieldPaths(checked.config) };
47
+ }
48
+ const inert = inertFieldLabels(checked.config);
49
+ if (inert.length > 0) {
50
+ return invalid(spell(`route: ${route} does not read ${inert.join(", ")}. Remove ${inert.length === 1 ? "it" : "them"}.`));
51
+ }
52
+ return { ok: true, config, warnings };
53
+ }
54
+ /**
55
+ * A v3 study in the v2 spelling: the actor holds its participants, `caps` moves under `execution`
56
+ * or `scenario`, and a shared world is `subject.topology: shared-world`. The checks that read more
57
+ * than one section run on this view until StudyConfig has the v3 shape.
58
+ */
59
+ export function toLegacy(study) {
60
+ const { route, participants, caps } = study;
61
+ const group = typeof participants === "object" && !Array.isArray(participants) ? participants : undefined;
62
+ const actor = withParticipants(study.actor, {
63
+ count: typeof participants === "number" ? participants : (group?.count ?? study.surfaces?.length),
64
+ entries: Array.isArray(participants) ? participants : undefined,
65
+ instruction: group?.instruction,
66
+ });
67
+ const { source, ...subject } = study.subject;
68
+ const execution = caps !== undefined && route !== "terminal" ? { ...study.execution, caps } : study.execution;
69
+ const scenario = {
70
+ ...(study.scenario === undefined ? {} : { ref: study.scenario }),
71
+ ...(study.mode === undefined ? {} : { mode: study.mode }),
72
+ ...(caps !== undefined && route === "terminal" ? { caps } : {}),
73
+ };
74
+ return {
75
+ schema: study.schema,
76
+ id: study.id,
77
+ ...(study.title === undefined ? {} : { title: study.title }),
78
+ ...(study.description === undefined ? {} : { description: study.description }),
79
+ subject: route === "shared-world" ? { source, topology: "shared-world", ...subject } : study.subject,
80
+ actors: [actor],
81
+ ...(execution === undefined ? {} : { execution }),
82
+ ...(Object.keys(scenario).length > 0 ? { scenario } : {}),
83
+ ...(study.policies === undefined ? {} : { policies: study.policies }),
84
+ ...(study.review === undefined ? {} : { review: study.review }),
85
+ ...(study.defaults === undefined ? {} : { defaults: study.defaults }),
86
+ ...(study.comms === undefined ? {} : { comms: study.comms }),
87
+ };
88
+ }
89
+ // Each section in the order the v2 parser reads it, so a file with more than one error reports the
90
+ // same one first.
91
+ function parseSections(raw, front) {
92
+ const id = str(raw.id);
93
+ if (!id || !ID_PATTERN.test(id)) {
94
+ return invalid("The study id must be a public-safe token starting with a letter or digit (/^[A-Za-z0-9][A-Za-z0-9_.-]*$/).");
95
+ }
96
+ const subject = parseSubject(raw.subject);
97
+ if (!subject.ok)
98
+ return subject;
99
+ const type = parseActorType(front.actor, "actor");
100
+ if (!type.ok)
101
+ return type;
102
+ const participants = parseParticipants(raw.participants, front.participants);
103
+ if (!participants.ok)
104
+ return participants;
105
+ const fields = parseActorFields(front.actor, "actor");
106
+ if (!fields.ok)
107
+ return fields;
108
+ const execution = parseExecution(raw.execution);
109
+ if (!execution.ok)
110
+ return execution;
111
+ const caps = parseCaps(raw.caps);
112
+ if (!caps.ok)
113
+ return caps;
114
+ if (isRecord(raw.policies) &&
115
+ raw.policies.mediaPermission !== undefined &&
116
+ raw.policies.mediaPermission !== "prompt" &&
117
+ raw.policies.mediaPermission !== "granted") {
118
+ return invalid("`policies.mediaPermission` must be `prompt` (the participant answers the browser's own dialog) or `granted`.");
119
+ }
120
+ const policies = parsePolicies(raw.policies);
121
+ if (!policies.ok)
122
+ return policies;
123
+ const review = parseReview(raw.review);
124
+ if (!review.ok)
125
+ return review;
126
+ if (isRecord(raw.defaults) && raw.defaults.open !== undefined) {
127
+ if (typeof raw.defaults.open !== "boolean")
128
+ return invalid("`defaults.open` must be true or false.");
129
+ }
130
+ const defaults = parseDefaults(raw.defaults);
131
+ const comms = parseComms(raw.comms);
132
+ if (!comms.ok)
133
+ return comms;
134
+ const mode = raw.mode === "dry-run" || raw.mode === "live" ? raw.mode : undefined;
135
+ const surfaces = front.surfaces === 2
136
+ ? ["desktop", "mobile"]
137
+ : front.surfaces === 1
138
+ ? ["desktop"]
139
+ : undefined;
140
+ const scenario = str(raw.scenario);
141
+ return {
142
+ ok: true,
143
+ value: {
144
+ schema: STUDY_SCHEMA,
145
+ id,
146
+ ...optionalStr("title", raw.title),
147
+ ...optionalStr("description", raw.description),
148
+ route: front.route,
149
+ ...(mode === undefined ? {} : { mode }),
150
+ subject: subject.value,
151
+ actor: { type: type.value, ...fields.value },
152
+ ...(participants.value === undefined ? {} : { participants: participants.value }),
153
+ ...(surfaces === undefined ? {} : { surfaces }),
154
+ ...(caps.value === undefined ? {} : { caps: caps.value }),
155
+ ...(execution.value === undefined ? {} : { execution: execution.value }),
156
+ ...(scenario === undefined ? {} : { scenario }),
157
+ ...(policies.value === undefined ? {} : { policies: policies.value }),
158
+ ...(review.value === undefined ? {} : { review: review.value }),
159
+ ...(defaults === undefined ? {} : { defaults }),
160
+ ...(comms.value === undefined ? {} : { comms: comms.value }),
161
+ },
162
+ };
163
+ }
164
+ // `participants` in the form the file wrote it. readStudyFront has checked the form against the
165
+ // route and expanded each list entry that has a count; the entries parse here, each named by the
166
+ // `participants` entry it came from. An instruction that is not a non-empty string is dropped, as
167
+ // the v2 parser drops it.
168
+ function parseParticipants(raw, read) {
169
+ if (read.entries !== undefined) {
170
+ const { source } = read;
171
+ return parseParticipantEntries(read.entries, {
172
+ list: "participants",
173
+ entry: (index) => `participants[${source[index] ?? index}]`,
174
+ });
175
+ }
176
+ if (typeof raw === "number")
177
+ return { ok: true, value: read.count };
178
+ if (!isRecord(raw))
179
+ return { ok: true, value: undefined };
180
+ const instruction = str(read.instruction);
181
+ return {
182
+ ok: true,
183
+ value: {
184
+ ...(read.count === undefined ? {} : { count: read.count }),
185
+ ...(instruction === undefined ? {} : { instruction }),
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * A caps block's values. A value that is not a non-negative number refuses the study: a cap that
191
+ * does nothing must not look like one that holds. readStudyFront has checked the keys against the
192
+ * route; migrate checks a v2 file's two blocks with it.
193
+ */
194
+ export function parseCaps(raw) {
195
+ if (raw === undefined)
196
+ return { ok: true, value: undefined };
197
+ if (!isRecord(raw)) {
198
+ return invalid("`caps` must be an object ({ maxUsd?, maxTotalUsd?, maxJobs?, maxMinutes? }).");
199
+ }
200
+ const caps = {};
201
+ for (const key of ["maxUsd", "maxTotalUsd", "maxJobs", "maxMinutes"]) {
202
+ if (raw[key] === undefined)
203
+ continue;
204
+ const value = nonNegNumber(raw[key]);
205
+ if (value === undefined)
206
+ return invalid(`\`caps.${key}\` must be a non-negative number.`);
207
+ caps[key] = value;
208
+ }
209
+ return { ok: true, value: Object.keys(caps).length > 0 ? caps : undefined };
210
+ }
211
+ // checkStudyConfig fills defaults in the v2 view: a shared world's `execution.concurrency`,
212
+ // `comms.email.recipients`, and the local browser's actor model and effort, execution timeout and
213
+ // resolution and `review.analysis`. Each has the same path in both shapes, so it is copied back.
214
+ function withFilledDefaults(study, checked) {
215
+ const [actor] = checked.actors;
216
+ const execution = { ...checked.execution };
217
+ delete execution.caps;
218
+ return {
219
+ ...study,
220
+ actor: actor === undefined ? study.actor : withoutParticipants(actor),
221
+ ...(Object.keys(execution).length > 0 ? { execution } : {}),
222
+ ...(checked.review === undefined ? {} : { review: checked.review }),
223
+ ...(checked.comms === undefined ? {} : { comms: checked.comms }),
224
+ };
225
+ }
@@ -176,7 +176,7 @@ function parseServedApp(raw, subject) {
176
176
  return stateResult;
177
177
  }
178
178
  if (stateResult.value) {
179
- // Semantic validation is shared with the engine (runCuaActorStudy re-enforces it for
179
+ // Semantic validation is shared with the engine (planComputerUseStudy re-enforces it for
180
180
  // configs that arrive through the library API without the parser).
181
181
  const reason = subjectStateInvalidReason(stateResult.value, subject.env);
182
182
  if (reason) {
@@ -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
@@ -6,6 +6,7 @@ import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../ru
6
6
  import { digestText, redactText } from "../evidence/redaction.js";
7
7
  import { realpath } from "node:fs/promises";
8
8
  import { rosterOf } from "./parse/actors.js";
9
+ import { actorsOf } from "./study-fields.js";
9
10
  /** Persona ids are file-name segments, never paths: the same grammar the terminal route enforces. */
10
11
  const PERSONA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
11
12
  /** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
@@ -86,7 +87,7 @@ export async function resolveCommittedPersonas(projectRoot, personaIds) {
86
87
  */
87
88
  export function studyPersonaIds(config) {
88
89
  const ids = [];
89
- for (const actor of config.actors ?? []) {
90
+ for (const actor of actorsOf(config)) {
90
91
  if (actor.persona)
91
92
  ids.push(actor.persona);
92
93
  for (const entry of rosterOf(actor) ?? []) {
@@ -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, route: "computer-use" | "shared-world"): 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()) {
@@ -43,8 +44,8 @@ function residualOf(config) {
43
44
  export function isNonEmpty(values) {
44
45
  return values.length > 0;
45
46
  }
46
- export function capsOf(config) {
47
- const caps = config.execution?.caps;
47
+ export function planCaps(config, route) {
48
+ const caps = capsOf(config, route);
48
49
  return {
49
50
  ...(caps?.maxUsd === undefined ? {} : { maxUsd: caps.maxUsd }),
50
51
  ...(caps?.maxTotalUsd === undefined ? {} : { maxTotalUsd: caps.maxTotalUsd }),
@@ -86,7 +87,7 @@ export function desktopRequirements(config, args) {
86
87
  return requirements;
87
88
  }
88
89
  export function brainOf(config, callerProvider) {
89
- const actor = config.actors[0];
90
+ const actor = actorOf(config);
90
91
  if (callerProvider)
91
92
  return callerBrainOf(config);
92
93
  const declared = declaredModelOf(config);
@@ -99,7 +100,7 @@ export function callerBrainOf(config) {
99
100
  return { kind: "caller", ...declaredModelOf(config) };
100
101
  }
101
102
  function declaredModelOf(config) {
102
- const model = config.actors[0]?.model;
103
+ const model = actorOf(config)?.model;
103
104
  return model === undefined ? {} : { declaredModel: model };
104
105
  }
105
106
  /** The model a participant's spend is priced at when its provider reports none. */
@@ -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
  }
@@ -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;
@@ -271,7 +271,7 @@ function targetUrlFor(config, target) {
271
271
  return config.subject.appUrl;
272
272
  }
273
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;
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
  }
@@ -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,7 +82,7 @@ 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
87
  targets.push(makeTarget(`actors[0].lanes[${index}].target`, "actors[0].lanes[].target", entry.target));
88
88
  }
@@ -1,5 +1,6 @@
1
1
  import { actorRegistry } from "../actors/registry.js";
2
2
  import { isLoopbackUrl } from "./parse/subject.js";
3
+ import { actorOf, participantList, declaredParticipantCount, declaresSharedWorld, } from "./study-fields.js";
3
4
  // Hard cap on computer-use participants. No setting raises it: each participant is a paid desktop,
4
5
  // and they all run at once.
5
6
  export const MAX_COMPUTER_USE_PARTICIPANTS = 16;
@@ -50,11 +51,7 @@ export function registeredTerminalActors() {
50
51
  * and the pre-flight plan so the participant count is computed the same way everywhere.
51
52
  */
52
53
  export function computerUseParticipantCount(config) {
53
- const actor = config.actors[0];
54
- if (actor?.lanes !== undefined) {
55
- return actor.lanes.length;
56
- }
57
- return actor?.count ?? 1;
54
+ return participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
58
55
  }
59
56
  /**
60
57
  * A participant's id: its declared roster id, else `lane-NN` (independent participants) or `role-NN`
@@ -77,7 +74,7 @@ export function isComputerUseComposition(config) {
77
74
  // local-app drives the cua loop in-process (a custom executor + a non-vision provider), so it
78
75
  // routes to the computer-use route exactly like an app-url subject with a computer-use actor.
79
76
  if (config.subject.source === "app-url" || config.subject.source === "local-app") {
80
- return actorResolvesToComputerUse(config.actors[0]?.type);
77
+ return actorResolvesToComputerUse(actorOf(config)?.type);
81
78
  }
82
79
  // desktop-cli hands the participant a terminal instead of a served page, but it is the
83
80
  // same route: same desktop, same actor, same prompt fields. Leaving it out of this predicate told
@@ -85,13 +82,13 @@ export function isComputerUseComposition(config) {
85
82
  // person reads a screen.
86
83
  if (config.subject.source === "desktop-cli") {
87
84
  return ((config.execution?.target === undefined || config.execution.target === "e2b-desktop") &&
88
- actorResolvesToComputerUse(config.actors[0]?.type));
85
+ actorResolvesToComputerUse(actorOf(config)?.type));
89
86
  }
90
87
  // local-tree packs+uploads the working tree, then serves it exactly like a computer-use clone
91
88
  // subject: same e2b-desktop + computer-use-actor gate.
92
89
  return ((config.subject.source === "clone" || config.subject.source === "local-tree") &&
93
90
  config.execution?.target === "e2b-desktop" &&
94
- actorResolvesToComputerUse(config.actors[0]?.type));
91
+ actorResolvesToComputerUse(actorOf(config)?.type));
95
92
  }
96
93
  /**
97
94
  * True when this config routes to the shared-world route: a clone or local-tree subject
@@ -108,9 +105,9 @@ export function isSharedWorldComposition(config) {
108
105
  /** The getHost provisioned-subject shared-world shape (clone/local-tree served + exposed in-sandbox). */
109
106
  export function isProvisionedSharedWorldComposition(config) {
110
107
  return ((config.subject.source === "clone" || config.subject.source === "local-tree") &&
111
- config.subject.topology === "shared-world" &&
108
+ declaresSharedWorld(config) &&
112
109
  config.execution?.target === "e2b-desktop" &&
113
- actorResolvesToComputerUse(config.actors[0]?.type));
110
+ actorResolvesToComputerUse(actorOf(config)?.type));
114
111
  }
115
112
  /**
116
113
  * The external-public shared-world shape: a real public deployment used directly as
@@ -123,9 +120,9 @@ export function isProvisionedSharedWorldComposition(config) {
123
120
  */
124
121
  export function isExternalPublicSharedWorldComposition(config) {
125
122
  return (config.subject.source === "app-url" &&
126
- config.subject.topology === "shared-world" &&
123
+ declaresSharedWorld(config) &&
127
124
  config.execution?.target === "e2b-desktop" &&
128
- actorResolvesToComputerUse(config.actors[0]?.type) &&
125
+ actorResolvesToComputerUse(actorOf(config)?.type) &&
129
126
  config.policies?.allowPublicTargets === true);
130
127
  }
131
128
  /**
@@ -162,12 +159,12 @@ export function isScriptedBrowserComposition(config) {
162
159
  return (isLocalScriptedBrowserComposition(config) || isProvisionedScriptedBrowserComposition(config));
163
160
  }
164
161
  function isLocalScriptedBrowserComposition(config) {
165
- return (config.subject.source === "app-url" && actorResolvesToScriptedBrowser(config.actors[0]?.type));
162
+ return (config.subject.source === "app-url" && actorResolvesToScriptedBrowser(actorOf(config)?.type));
166
163
  }
167
164
  export function isProvisionedScriptedBrowserComposition(config) {
168
165
  return (config.subject.source === "clone" &&
169
166
  config.execution?.target === "e2b-desktop" &&
170
- actorResolvesToScriptedBrowser(config.actors[0]?.type));
167
+ actorResolvesToScriptedBrowser(actorOf(config)?.type));
171
168
  }
172
169
  /**
173
170
  * True when this config routes to the terminal-product backend: a terminal-product subject whose
@@ -176,7 +173,7 @@ export function isProvisionedScriptedBrowserComposition(config) {
176
173
  * the single source of truth for routeOf and the warning logic.
177
174
  */
178
175
  export function isTerminalProductComposition(config) {
179
- return (config.subject.source === "terminal-product" && actorResolvesToTerminal(config.actors[0]?.type));
176
+ return (config.subject.source === "terminal-product" && actorResolvesToTerminal(actorOf(config)?.type));
180
177
  }
181
178
  /**
182
179
  * The route a config takes. It never refuses: a config no route can run still gets the route
@@ -6,8 +6,8 @@ import path from "node:path";
6
6
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
7
7
  import { resolveStudyDryRun } from "./plan.js";
8
8
  import { knownSecretValues, studyEventEmitter } from "./run-study-events.js";
9
- import { rosterOf } from "./parse/actors.js";
10
9
  import { studyResultIdentity } from "../run/study-result.js";
10
+ import { actorOf, participantList } from "./study-fields.js";
11
11
  const unsupported = (option, route, reason) => ({
12
12
  ok: false,
13
13
  code: "HUMANISH_STUDY_OPTION_UNSUPPORTED",
@@ -105,7 +105,7 @@ export function normalizeRunStudyOptions(config, route, options) {
105
105
  export function optionRefusalOutcome(config, route, options, refusal) {
106
106
  const cwd = path.resolve(options.cwd);
107
107
  const error = { code: refusal.code, message: refusal.message };
108
- const actor = config.actors[0]?.type ?? "";
108
+ const actor = actorOf(config)?.type ?? "";
109
109
  const dryRun = resolveStudyDryRun(config, options.dryRun, true) ?? true;
110
110
  const runId = options.runId ?? "not-created";
111
111
  const common = { ok: false, cwd, actor, dryRun, runId, warnings: [], error };
@@ -154,7 +154,7 @@ export function optionRefusalOutcome(config, route, options, refusal) {
154
154
  },
155
155
  };
156
156
  case "shared-world": {
157
- const participantCount = rosterOf(config.actors[0])?.length ?? 0;
157
+ const participantCount = participantList(config)?.length ?? 0;
158
158
  return {
159
159
  route: "shared-world",
160
160
  result: {
@@ -0,0 +1,28 @@
1
+ import type { StudyRoute } from "./routing.js";
2
+ import type { StudyActor, StudyConfig, StudyParticipantEntry, StudyScenario, StudyScenarioCaps } from "./types.js";
3
+ /** The actor that runs. A v3 file declares it as `actor`. */
4
+ export declare function actorOf(config: StudyConfig): StudyActor | undefined;
5
+ /**
6
+ * Every declared actor. parseStudy accepts exactly one; a library config may declare more, and the
7
+ * checks that refuse a second actor's settings read them all.
8
+ */
9
+ export declare function actorsOf(config: StudyConfig): readonly StudyActor[];
10
+ /** The participants listed one by one: a v3 `participants` list. Undefined when none is listed. */
11
+ export declare function participantList(config: StudyConfig): readonly StudyParticipantEntry[] | undefined;
12
+ /** The declared number of identical participants: v3 `participants: <n>` or `participants.count`. */
13
+ export declare function declaredParticipantCount(config: StudyConfig): number | undefined;
14
+ /** The steer each of a counted group of participants gets: v3 `participants.instruction`. */
15
+ export declare function participantInstruction(config: StudyConfig): string | undefined;
16
+ /** How many scripted surfaces run, 1 for desktop and 2 with mobile. A v3 file lists `surfaces`. */
17
+ export declare function surfaceCount(config: StudyConfig): number | undefined;
18
+ /** The declared run mode. A v3 file declares it as `mode`. */
19
+ export declare function modeOf(config: StudyConfig): StudyScenario["mode"];
20
+ /** The scripted route's scenario id or path. A v3 file declares it as `scenario`. */
21
+ export declare function scenarioRefOf(config: StudyConfig): string | undefined;
22
+ /**
23
+ * The spend caps a route enforces. A v3 file declares them as `caps`. The v2 shape keeps them in
24
+ * `execution.caps` on computer use and shared world, and in `scenario.caps` on terminal.
25
+ */
26
+ export declare function capsOf(config: StudyConfig, route: Extract<StudyRoute, "computer-use" | "shared-world" | "terminal">): StudyScenarioCaps | undefined;
27
+ /** True when the participants share one app. A v3 file declares `route: shared-world`. */
28
+ export declare function declaresSharedWorld(config: StudyConfig): boolean;
@@ -0,0 +1,51 @@
1
+ // The study's fields, read in one place. StudyConfig still has the humanish.lab.v2 shape
2
+ // (`actors[0]`, `execution.caps`, `scenario.mode`), while a humanish.study.v3 file names the same
3
+ // values `actor`, `participants`, `surfaces`, `caps`, `mode`, `scenario` and `route`. Code outside
4
+ // the parser reads them through these functions, and tests/surface/study-config-reads.test.ts
5
+ // refuses a direct read, so the config can take the v3 shape by changing this file.
6
+ /** The actor that runs. A v3 file declares it as `actor`. */
7
+ export function actorOf(config) {
8
+ return config.actors[0];
9
+ }
10
+ /**
11
+ * Every declared actor. parseStudy accepts exactly one; a library config may declare more, and the
12
+ * checks that refuse a second actor's settings read them all.
13
+ */
14
+ export function actorsOf(config) {
15
+ return config.actors;
16
+ }
17
+ /** The participants listed one by one: a v3 `participants` list. Undefined when none is listed. */
18
+ export function participantList(config) {
19
+ return config.actors[0]?.lanes;
20
+ }
21
+ /** The declared number of identical participants: v3 `participants: <n>` or `participants.count`. */
22
+ export function declaredParticipantCount(config) {
23
+ return config.actors[0]?.count;
24
+ }
25
+ /** The steer each of a counted group of participants gets: v3 `participants.instruction`. */
26
+ export function participantInstruction(config) {
27
+ return config.actors[0]?.laneFocus?.instruction;
28
+ }
29
+ /** How many scripted surfaces run, 1 for desktop and 2 with mobile. A v3 file lists `surfaces`. */
30
+ export function surfaceCount(config) {
31
+ return config.actors[0]?.count;
32
+ }
33
+ /** The declared run mode. A v3 file declares it as `mode`. */
34
+ export function modeOf(config) {
35
+ return config.scenario?.mode;
36
+ }
37
+ /** The scripted route's scenario id or path. A v3 file declares it as `scenario`. */
38
+ export function scenarioRefOf(config) {
39
+ return config.scenario?.ref;
40
+ }
41
+ /**
42
+ * The spend caps a route enforces. A v3 file declares them as `caps`. The v2 shape keeps them in
43
+ * `execution.caps` on computer use and shared world, and in `scenario.caps` on terminal.
44
+ */
45
+ export function capsOf(config, route) {
46
+ return route === "terminal" ? config.scenario?.caps : config.execution?.caps;
47
+ }
48
+ /** True when the participants share one app. A v3 file declares `route: shared-world`. */
49
+ export function declaresSharedWorld(config) {
50
+ return config.subject.topology === "shared-world";
51
+ }