humanish 0.110.4 → 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 (79) hide show
  1. package/CHANGELOG.md +15 -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/cli/commands/study.js +2 -4
  7. package/dist/routes/computer-use/participant-model.js +1 -1
  8. package/dist/routes/computer-use/plan.js +4 -6
  9. package/dist/routes/computer-use/preflight.js +1 -1
  10. package/dist/routes/computer-use/result.js +1 -1
  11. package/dist/routes/computer-use/route.js +1 -1
  12. package/dist/routes/computer-use/types.d.ts +4 -4
  13. package/dist/routes/scripted/route.js +1 -1
  14. package/dist/routes/scripted/scenario.d.ts +1 -1
  15. package/dist/routes/scripted/scenario.js +3 -3
  16. package/dist/routes/scripted/types.d.ts +2 -2
  17. package/dist/routes/shared-world/handoff.js +1 -1
  18. package/dist/routes/shared-world/participant-specs.js +1 -1
  19. package/dist/routes/shared-world/plan.js +3 -4
  20. package/dist/routes/shared-world/types.d.ts +1 -1
  21. package/dist/routes/terminal/bundle.d.ts +3 -3
  22. package/dist/routes/terminal/ledger.d.ts +2 -2
  23. package/dist/routes/terminal/lifetime.js +1 -1
  24. package/dist/routes/terminal/live-finish.js +1 -1
  25. package/dist/routes/terminal/plan.js +1 -1
  26. package/dist/routes/terminal/route.js +1 -1
  27. package/dist/routes/terminal/types.d.ts +1 -1
  28. package/dist/run/results.d.ts +1 -1
  29. package/dist/run-study.d.ts +5 -3
  30. package/dist/run-study.js +7 -11
  31. package/dist/study/adapter-scorer-loader.d.ts +1 -1
  32. package/dist/study/adapter-scorer-loader.js +3 -3
  33. package/dist/study/composition-rules.js +21 -11
  34. package/dist/study/config.d.ts +29 -13
  35. package/dist/study/config.js +174 -157
  36. package/dist/study/discover.d.ts +1 -1
  37. package/dist/study/discover.js +1 -1
  38. package/dist/study/keys.d.ts +250 -2
  39. package/dist/study/keys.js +32 -62
  40. package/dist/study/migrate/convert.js +3 -4
  41. package/dist/study/migrate/v2.d.ts +17 -7
  42. package/dist/study/migrate/v2.js +150 -23
  43. package/dist/study/parse/actors.d.ts +11 -32
  44. package/dist/study/parse/actors.js +11 -156
  45. package/dist/study/parse/comms.js +6 -2
  46. package/dist/study/parse/execution.d.ts +9 -4
  47. package/dist/study/parse/execution.js +4 -39
  48. package/dist/study/parse/{study-v3.d.ts → front.d.ts} +6 -17
  49. package/dist/study/parse/{study-v3.js → front.js} +22 -91
  50. package/dist/study/parse/subject.d.ts +2 -0
  51. package/dist/study/parse/subject.js +15 -18
  52. package/dist/study/persona-resolve.js +7 -9
  53. package/dist/study/plan-base.d.ts +1 -1
  54. package/dist/study/plan-base.js +3 -4
  55. package/dist/study/plan-participants.d.ts +2 -2
  56. package/dist/study/plan-types.d.ts +5 -5
  57. package/dist/study/plan.js +1 -1
  58. package/dist/study/preflight-probes.js +2 -2
  59. package/dist/study/preflight.d.ts +1 -1
  60. package/dist/study/preflight.js +1 -1
  61. package/dist/study/routing.d.ts +5 -5
  62. package/dist/study/routing.js +5 -5
  63. package/dist/study/run-study-options.d.ts +1 -1
  64. package/dist/study/study-deps.d.ts +1 -1
  65. package/dist/study/study-fields.d.ts +12 -21
  66. package/dist/study/study-fields.js +29 -34
  67. package/dist/study/summary.js +9 -4
  68. package/dist/study/types.d.ts +50 -128
  69. package/dist/study/types.js +0 -1
  70. package/dist/study/url-credentials.js +3 -4
  71. package/dist/study/validation.d.ts +2 -10
  72. package/dist/study/validation.js +16 -46
  73. package/dist/study/warnings.d.ts +3 -3
  74. package/dist/study/warnings.js +40 -109
  75. package/dist/substrates/local/runtime-config.js +9 -11
  76. package/docs/contracts/schemas.md +23 -4
  77. package/package.json +1 -1
  78. package/dist/study/parse/study.d.ts +0 -38
  79. package/dist/study/parse/study.js +0 -225
@@ -1,9 +1,9 @@
1
1
  import { isMaxOutputTokens } from "../actors/output-token-limit.js";
2
- import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN, focusOf } from "./parse/actors.js";
2
+ import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./parse/actors.js";
3
3
  import { isHttpUrl, isLoopbackUrl } from "./parse/subject.js";
4
4
  import { declaredTargets } from "./plan-participants.js";
5
5
  import { actorResolvesToComputerUse, computerUseParticipantCount, MAX_COMPUTER_USE_PARTICIPANTS, registeredComputerUseActors, resolveEntryUrl, isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
6
- import { actorOf, actorsOf, participantList, declaredParticipantCount, declaresSharedWorld, } from "./study-fields.js";
6
+ import { actorOf, participantList, declaresSharedWorld } from "./study-fields.js";
7
7
  /**
8
8
  * Cross-validate the computer-use fan-out declaration (`per-lane-worlds`). Returns the failure
9
9
  * message, or null when valid. The parser enforces it, and runStudyWith checks it again for a config
@@ -11,25 +11,18 @@ import { actorOf, actorsOf, participantList, declaredParticipantCount, declaresS
11
11
  * validity, unique ids) first, then the route-scoped XOR, cap and policy checks.
12
12
  */
13
13
  export function computerUseValidationReason(config) {
14
- const actor = actorOf(config);
15
14
  const roster = participantList(config);
16
15
  const structuralReason = rosterStructuralValidationReason(config);
17
16
  if (structuralReason) {
18
17
  return structuralReason;
19
18
  }
20
19
  // clone.fanout is a declared behavior change: rejected on the computer-use route (was
21
- // inert-warned). Fan-out is declared via `actors[0].count` or `actors[0].lanes`;
22
- // subject.clone.fanout never applied here.
20
+ // inert-warned). Fan-out is declared with `participants`; subject.clone.fanout never applied
21
+ // here.
23
22
  if (config.subject.clone?.fanout !== undefined) {
24
23
  return "`subject.clone.fanout` is not used on the computer-use route: declare fan-out with participants (homogeneous) or participants (a roster of participants). (No current route reads clone.fanout.)";
25
24
  }
26
25
  if (roster !== undefined) {
27
- if (declaredParticipantCount(config) !== undefined) {
28
- return "Set either `actors[0].count` (identical participants) or `actors[0].lanes` (a roster of distinct participants), not both.";
29
- }
30
- if (focusOf(actor) !== undefined) {
31
- return "actors[0].laneFocus and actors[0].lanes are mutually exclusive: each roster entry's `instruction` is the fan-out steer; laneFocus is the steer for a single participant.";
32
- }
33
26
  if (config.execution?.desktop?.resolution !== undefined &&
34
27
  roster.some((entry) => entry.device !== undefined)) {
35
28
  return "participants[].device and a raw execution.desktop.resolution are mutually exclusive: a per-participant device preset and a single hand-set resolution cannot both govern participant geometry.";
@@ -54,7 +47,7 @@ export function computerUseValidationReason(config) {
54
47
  // Public targets fan out into N independent worlds driving the same public app, which is an
55
48
  // ambiguous shared-world-ish shape, not a per-participant target swarm. Permit N>1 public runs only
56
49
  // when every roster entry declares its own target, making the adapter-owned topology explicit. But when
57
- // `subject.topology: shared-world` is also declared, N participants against one public target is the
50
+ // `route: shared-world` is also declared, N participants against one public target is the
58
51
  // external-public shared-world topology, so route it there (a real public deployment
59
52
  // as the shared plane) instead of refusing; externalPublicSharedWorldValidationReason then applies.
60
53
  if (participantCount > 1 &&
@@ -98,7 +91,7 @@ function rosterStructuralValidationReason(config) {
98
91
  return null;
99
92
  }
100
93
  /**
101
- * Cross-validate a `topology: shared-world` declaration. Returns the failure message, or
94
+ * Cross-validate a `route: shared-world` declaration. Returns the failure message, or
102
95
  * null when the one shared-world route can run it: the external-public checks for an app-url
103
96
  * subject, the provisioned checks otherwise. Enforced at parse and again by the route, since
104
97
  * runStudyWith takes a config that skipped the parser.
@@ -110,8 +103,8 @@ export function sharedWorldValidationReason(config) {
110
103
  }
111
104
  /**
112
105
  * The structural checks every provisioned shared world shares. It requires: a clone or local-tree source + e2b-desktop
113
- * target + a computer-use actor + a `subject.serve` block + an `actors[0].lanes` roster of ≥2 roles (the
114
- * roster is the role roster; there is no separate roles[] field), and every role `entry` must resolve
106
+ * target + a computer-use actor + a `subject.serve` block + a `participants` list of ≥2 (the list
107
+ * is the role roster; there is no separate roles[] field), and every role `entry` must resolve
115
108
  * same-origin (loopback) with serve.url. Fail-closed: a half-declared shared-world is rejected,
116
109
  * never silently downgraded.
117
110
  */
@@ -181,8 +174,10 @@ export function desktopMediaValidationReason(config, supportsMedia = isComputerU
181
174
  return "execution.desktop.media requires Chrome or Chromium; Firefox cannot receive the declared synthetic capture device. Set execution.desktop.browser: chrome or chromium.";
182
175
  }
183
176
  if (media.microphone !== undefined) {
184
- if (actorsOf(config).some((actor) => actor.type !== "local-agent" ||
185
- (actor.localAgent !== undefined && actor.localAgent !== "codex"))) {
177
+ const actor = actorOf(config);
178
+ if (actor !== undefined &&
179
+ (actor.type !== "local-agent" ||
180
+ (actor.localAgent !== undefined && actor.localAgent !== "codex"))) {
186
181
  return "Participant speech currently requires local-agent with Codex, on a local or hosted desktop.";
187
182
  }
188
183
  if (config.execution?.target !== "local" && media.camera !== undefined) {
@@ -199,7 +194,7 @@ export function receivingEmailValidationReason(config) {
199
194
  !["app-url", "clone", "local-tree"].includes(config.subject.source)) {
200
195
  return "Real email receiving requires a hosted computer-use browser study with an app-url, clone, or local-tree subject. Scripted, terminal, desktop-cli and local-app routes are unsupported.";
201
196
  }
202
- if (actorsOf(config).some((actor) => actor.type === "local-agent")) {
197
+ if (actorOf(config)?.type === "local-agent") {
203
198
  return "Real email receiving is unavailable for local-agent: its host process does not isolate the inbox management credential. Use a hosted first-party computer-use actor.";
204
199
  }
205
200
  return undefined;
@@ -207,15 +202,8 @@ export function receivingEmailValidationReason(config) {
207
202
  /** Refuse task declarations that the selected execution path would discard.
208
203
  * Direct runners pass their actual support rather than trusting the config's dispatch shape. */
209
204
  export function taskProtocolValidationReason(config, supportsTasks = isComputerUseComposition(config) && !isSharedWorldComposition(config)) {
210
- for (const [index, actor] of actorsOf(config).entries()) {
211
- if (actor.tasks === undefined)
212
- continue;
213
- if (index > 0) {
214
- return `actors[${index}].tasks is unsupported: current runners consume only actors[0]. Use the first actor's computer-use participants for a task protocol.`;
215
- }
216
- if (!supportsTasks) {
217
- return "actor.tasks is unsupported on this execution path. Task protocols require the computer-use route, with one world per participant; shared-world, terminal-product, scripted-browser and synthetic routes do not consume them. Remove tasks only if a mission-only study is intended.";
218
- }
205
+ if (actorOf(config)?.tasks !== undefined && !supportsTasks) {
206
+ return "actor.tasks is unsupported on this execution path. Task protocols require the computer-use route, with one world per participant; shared-world, terminal-product, scripted-browser and synthetic routes do not consume them. Remove tasks only if a mission-only study is intended.";
219
207
  }
220
208
  return null;
221
209
  }
@@ -233,24 +221,6 @@ export function cloneTargetValidationReason(config) {
233
221
  const got = target === undefined ? "it is absent" : `got "${target}"`;
234
222
  return `clone subjects require \`execution.target: e2b-desktop\` (${got}): humanish clones and serves the repo inside a hosted desktop sandbox. \`execution.target: local\` applies to app-url and local-app subjects.`;
235
223
  }
236
- /**
237
- * Refuse a positive `scenario.caps.maxUsd` or `maxTotalUsd` on a computer-use study.
238
- * `scenario.caps` belongs to the terminal route; the computer-use route stops on `execution.caps`,
239
- * so a dollar figure here would read as a cap while the study ran uncapped. Zero spends nothing
240
- * either way and stays a warning. Enforced at parse and again on the computer-use routes for
241
- * library callers.
242
- */
243
- export function scenarioCapsValidationReason(config) {
244
- if (!isComputerUseComposition(config))
245
- return null;
246
- for (const key of ["maxUsd", "maxTotalUsd"]) {
247
- const value = config.scenario?.caps?.[key];
248
- if (value === undefined || value <= 0)
249
- continue;
250
- return `scenario.caps.${key} (${value}) does not cap a computer-use study: this route stops on execution.caps.${key}. Move the value to execution.caps.${key}; scenario.caps applies only to terminal-product studies.`;
251
- }
252
- return null;
253
- }
254
224
  /** Refuse a claimed output bound when the route cannot pass it to the first-party provider. */
255
225
  export function outputTokenLimitValidationReason(config) {
256
226
  const actor = actorOf(config);
@@ -317,7 +287,7 @@ export function concurrentSharedWorldValidationReason(config) {
317
287
  * it rejects every provisioned-subject field (serve/state.seed/state.checkpoint/exposure/clone/repos
318
288
  * are inert with no sandbox, so they fail closed), and requires a
319
289
  * non-loopback appUrl + allowPublicTargets + the operator-ownership attestation subject.publicTarget +
320
- * concurrency >= 2 + an actors[0].lanes roster of ≥2 with exactly one host participant. The getHost synthetic
290
+ * concurrency >= 2 + a `participants` list of ≥2 with exactly one host participant. The getHost synthetic
321
291
  * gate is deliberately unreachable here (there is no internet-reachable harness-owned URL to attest).
322
292
  * Enforced at parse and re-enforced in the engine (runStudyWith takes a config that skipped the parser).
323
293
  */
@@ -1,12 +1,12 @@
1
1
  import type { StudyConfig } from "./types.js";
2
2
  /**
3
- * The fields a config sets that its route does not read, each as `field` or `field (reason)`. A v2
4
- * file gets them as one warning; a v3 study is refused for any of them.
3
+ * The fields a config sets that its route does not read, each as `field` or `field (reason)`.
4
+ * parseStudy refuses a study for any of them.
5
5
  */
6
6
  export declare function inertFieldLabels(config: StudyConfig): string[];
7
7
  /**
8
8
  * The same fields as `inertFieldLabels`, as paths without their reasons, such as
9
- * `actors[0].lanes[].entry` or `execution.timeoutMs`. `humanish migrate` drops each one.
9
+ * `participants[].entry` or `execution.timeoutMs`. `humanish migrate` drops each one.
10
10
  */
11
11
  export declare function inertFieldPaths(config: StudyConfig): string[];
12
12
  export declare function forwardDeclaredWarnings(config: StudyConfig): string[];
@@ -1,73 +1,54 @@
1
1
  import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
2
2
  import { declaredParticipantIds } from "./plan-participants.js";
3
3
  import { addressedRecipients } from "./parse/comms.js";
4
- import { focusOf, rosterOf } from "./parse/actors.js";
5
- import { actorsOf, participantList, declaredParticipantCount, scenarioRefOf, } from "./study-fields.js";
6
- // The computer-use and terminal routes consume mission/persona/model and laneFocus.instruction (they
7
- // compose the agent prompt and bundle provenance); laneFocus.id/label stay inert. On the computer-use
8
- // E2B route actors[0].lanes is consumed (the fan-out roster).
4
+ import { actorOf, participantList, declaredParticipantCount, scenarioRefOf, } from "./study-fields.js";
5
+ // The computer-use and terminal routes consume mission/persona/model (they compose the agent prompt
6
+ // and bundle provenance).
9
7
  const promptRoute = (routes) => routes.cua || routes.terminal;
10
- // The scripted-browser route consumes persona and count (trace/bundle provenance; surface roster).
11
- // The prompt fields can never act there: the scripted actor runs no model.
8
+ // The scripted-browser route consumes persona and surfaces (trace/bundle provenance). The prompt
9
+ // fields can never act there: the scripted actor runs no model.
12
10
  const scriptedRoute = (routes) => !promptRoute(routes) && routes.scripted;
13
11
  const otherRoute = (routes) => !promptRoute(routes) && !routes.scripted;
14
- /** Rows for each actor, reported as `actors[<index>].<field>`, in this order per actor. */
12
+ /** Rows for the actor and its participants, reported first, in this order. */
15
13
  const ACTOR_ROWS = [
16
14
  // Shared-world-only fields on the roster: a participant's `entry` is inert anywhere else
17
15
  // (claims match mechanism).
18
16
  {
19
- field: "lanes[].entry",
20
- reason: "the per-participant loopback entry is a shared-world capability; needs subject.topology: shared-world",
21
- applies: (actor, routes) => Boolean(rosterOf(actor)?.some((entry) => entry.entry !== undefined)) && !routes.shared,
17
+ field: "participants[].entry",
18
+ reason: "the per-participant loopback entry is a shared-world capability; needs route: shared-world",
19
+ applies: (config, routes) => Boolean(participantList(config)?.some((entry) => entry.entry !== undefined)) &&
20
+ !routes.shared,
22
21
  },
23
22
  // The host marker acts only on the external-public shared-world route; inert elsewhere.
24
23
  {
25
- field: "lanes[].host",
24
+ field: "participants[].host",
26
25
  reason: "the designated host-participant marker; needs the external-public shared-world route: app-url × topology shared-world × allowPublicTargets",
27
- applies: (actor, routes) => Boolean(rosterOf(actor)?.some((entry) => entry.host === true)) && !routes.externalPublic,
26
+ applies: (config, routes) => Boolean(participantList(config)?.some((entry) => entry.host === true)) &&
27
+ !routes.externalPublic,
28
28
  },
29
29
  {
30
- field: "laneFocus.id",
31
- applies: (actor, routes) => promptRoute(routes) && Boolean(focusOf(actor)?.id),
32
- },
33
- {
34
- field: "laneFocus.label",
35
- applies: (actor, routes) => promptRoute(routes) && Boolean(focusOf(actor)?.label),
36
- },
37
- {
38
- field: "lanes",
39
- reason: "only the computer-use route runs more than one participant",
40
- applies: (actor, routes) => routes.terminal && Boolean(rosterOf(actor)),
41
- },
42
- {
43
- field: "mission",
30
+ field: "actor.mission",
44
31
  reason: "the scripted-browser actor runs no model",
45
- applies: (actor, routes) => scriptedRoute(routes) && Boolean(actor.mission),
32
+ applies: (config, routes) => scriptedRoute(routes) && Boolean(actorOf(config)?.mission),
46
33
  },
47
34
  {
48
- field: "laneFocus",
35
+ field: "actor.model",
49
36
  reason: "the scripted-browser actor runs no model",
50
- applies: (actor, routes) => scriptedRoute(routes) && Boolean(focusOf(actor)),
37
+ applies: (config, routes) => scriptedRoute(routes) && Boolean(actorOf(config)?.model),
51
38
  },
39
+ // On every other route the prompt fields and persona are inert.
52
40
  {
53
- field: "model",
54
- reason: "the scripted-browser actor runs no model",
55
- applies: (actor, routes) => scriptedRoute(routes) && Boolean(actor.model),
41
+ field: "actor.mission",
42
+ applies: (config, routes) => otherRoute(routes) && Boolean(actorOf(config)?.mission),
56
43
  },
57
44
  {
58
- field: "lanes",
59
- reason: "the scripted-browser route fans out via actors[0].count, not an actors[0].lanes roster",
60
- applies: (actor, routes) => scriptedRoute(routes) && Boolean(rosterOf(actor)),
45
+ field: "actor.persona",
46
+ applies: (config, routes) => otherRoute(routes) && Boolean(actorOf(config)?.persona),
61
47
  },
62
- // On every other route the prompt fields, persona and the `lanes[]` roster are inert.
63
- { field: "mission", applies: (actor, routes) => otherRoute(routes) && Boolean(actor.mission) },
64
48
  {
65
- field: "laneFocus",
66
- applies: (actor, routes) => otherRoute(routes) && Boolean(focusOf(actor)),
49
+ field: "actor.model",
50
+ applies: (config, routes) => otherRoute(routes) && Boolean(actorOf(config)?.model),
67
51
  },
68
- { field: "persona", applies: (actor, routes) => otherRoute(routes) && Boolean(actor.persona) },
69
- { field: "model", applies: (actor, routes) => otherRoute(routes) && Boolean(actor.model) },
70
- { field: "lanes", applies: (actor, routes) => otherRoute(routes) && Boolean(rosterOf(actor)) },
71
52
  ];
72
53
  const TERMINAL_ONLY = "needs subject.source: terminal-product + a registered terminal actor";
73
54
  const RESERVED = "reserved; no route reads it yet";
@@ -91,17 +72,10 @@ const CONFIG_ROWS = [
91
72
  field: "subject.state",
92
73
  applies: (config, routes) => Boolean(config.subject.state) && !routes.cua && !routes.scripted,
93
74
  },
94
- // topology + checkpoint act only on the shared-world route; a set-but-unconsumed value
95
- // (incl. an explicit per-lane-worlds, which the computer-use route already is by mechanism) warns
96
- // inert.
97
- {
98
- field: "subject.topology",
99
- reason: "drives behavior only on the shared-world route; needs subject.topology: shared-world + clone × e2b-desktop × a computer-use actor + a roster of ≥2 participants",
100
- applies: (config, routes) => config.subject.topology !== undefined && !routes.shared,
101
- },
75
+ // The checkpoint acts only on the shared-world route.
102
76
  {
103
77
  field: "subject.state.checkpoint",
104
- reason: "the shared-world state-checkpoint probe; needs subject.topology: shared-world",
78
+ reason: "the shared-world state-checkpoint probe; needs route: shared-world",
105
79
  applies: (config, routes) => config.subject.state?.checkpoint !== undefined && !routes.shared,
106
80
  },
107
81
  // publicTarget (the external-public ownership attestation) acts only on the external-public
@@ -161,18 +135,10 @@ const CONFIG_ROWS = [
161
135
  field: "execution.concurrency",
162
136
  applies: (config, routes) => config.execution?.concurrency !== undefined && !routes.cua,
163
137
  },
164
- // execution.caps is consumed on the computer-use route (maxUsd is the fail-closed spend abort);
165
- // inert (warned) everywhere else so a misplaced budget field is never trusted to cap a route it
166
- // cannot.
167
- {
168
- field: "execution.caps",
169
- reason: "only the computer-use route enforces it, with a computer-use actor on e2b-desktop",
170
- applies: (config, routes) => Boolean(config.execution?.caps) && !routes.cua,
171
- },
172
- // terminal-product consumes subject.product, scenario.caps, execution.{terminal,runtimeAuth}:
173
- // dry-run records the contract; live execution enforces caps and command-scoped auth. On every
174
- // other route they are inert and must warn so a
175
- // misplaced safety/budget field is never trusted to do something it cannot.
138
+ // parse/front.ts refuses a `caps` key the declared route does not read, so no row lists `caps`.
139
+ // terminal-product consumes subject.product and execution.{terminal,runtimeAuth}: dry-run records
140
+ // the contract; live execution enforces command-scoped auth. On every other route they are inert
141
+ // and must warn so a misplaced safety field is never trusted to do something it cannot.
176
142
  {
177
143
  field: "subject.product",
178
144
  reason: "needs subject.source: terminal-product or desktop-cli with the matching actor",
@@ -180,18 +146,6 @@ const CONFIG_ROWS = [
180
146
  !routes.terminal &&
181
147
  config.subject.source !== "desktop-cli",
182
148
  },
183
- {
184
- field: "scenario.caps",
185
- reason: TERMINAL_ONLY,
186
- applies: (config, routes) => Boolean(config.scenario?.caps) && !routes.terminal,
187
- },
188
- // The study-level budget is a CUA-route capability; the terminal route is a single agent whose
189
- // maxUsd already caps the whole run, so a maxTotalUsd there would be trusted and unenforced.
190
- {
191
- field: "scenario.caps.maxTotalUsd",
192
- reason: "the study-level budget is a computer-use route capability; the terminal route's maxUsd already caps the whole run",
193
- applies: (config, routes) => config.scenario?.caps?.maxTotalUsd !== undefined && routes.terminal,
194
- },
195
149
  {
196
150
  field: "execution.terminal",
197
151
  reason: TERMINAL_ONLY,
@@ -247,13 +201,12 @@ const CONFIG_ROWS = [
247
201
  reason: "no current route reads it",
248
202
  applies: (config) => config.execution?.desktop?.codexAppServer !== undefined,
249
203
  },
250
- // scenario.ref is consumed on the scripted-browser route (required there); forward-declared
204
+ // `scenario` is consumed on the scripted-browser route (required there); forward-declared
251
205
  // everywhere else.
252
206
  {
253
- field: "scenario.ref",
207
+ field: "scenario",
254
208
  applies: (config, routes) => Boolean(scenarioRefOf(config)) && !routes.scripted,
255
209
  },
256
- { field: "scenario.inline", applies: (config) => Boolean(config.scenario?.inline) },
257
210
  // review.{scoring,milestones,vocabulary} stay forward-declared on every route.
258
211
  // review.scorer is consumed (loaded + wired, or fail-closed at load) on every scorer-capable
259
212
  // route, so it does not warn there; on the scripted-browser route the actor carries no scorer seam,
@@ -278,7 +231,6 @@ const CONFIG_ROWS = [
278
231
  reason: "the scripted-browser actor has no adopter-scorer seam; declare it on a terminal / computer-use / shared-world route",
279
232
  applies: (config, routes) => Boolean(config.review?.scorer) && routes.scripted,
280
233
  },
281
- { field: "personas", applies: (config) => Boolean(config.personas) },
282
234
  ];
283
235
  function routesOf(config) {
284
236
  const cua = isComputerUseComposition(config);
@@ -295,44 +247,23 @@ function routesOf(config) {
295
247
  config.execution?.target === "e2b-desktop",
296
248
  };
297
249
  }
298
- function rowLabel(row, prefix = "") {
299
- return row.reason === undefined
300
- ? `${prefix}${row.field}`
301
- : `${prefix}${row.field} (${row.reason})`;
250
+ function inertRows(config) {
251
+ const routes = routesOf(config);
252
+ return [...ACTOR_ROWS, ...CONFIG_ROWS].filter((row) => row.applies(config, routes));
302
253
  }
303
254
  /**
304
- * The fields a config sets that its route does not read, each as `field` or `field (reason)`. A v2
305
- * file gets them as one warning; a v3 study is refused for any of them.
255
+ * The fields a config sets that its route does not read, each as `field` or `field (reason)`.
256
+ * parseStudy refuses a study for any of them.
306
257
  */
307
258
  export function inertFieldLabels(config) {
308
- const routes = routesOf(config);
309
- const inert = [];
310
- for (const [index, actor] of actorsOf(config).entries()) {
311
- for (const row of ACTOR_ROWS)
312
- if (row.applies(actor, routes))
313
- inert.push(rowLabel(row, `actors[${index}].`));
314
- }
315
- for (const row of CONFIG_ROWS)
316
- if (row.applies(config, routes))
317
- inert.push(rowLabel(row));
318
- return inert;
259
+ return inertRows(config).map((row) => row.reason === undefined ? row.field : `${row.field} (${row.reason})`);
319
260
  }
320
261
  /**
321
262
  * The same fields as `inertFieldLabels`, as paths without their reasons, such as
322
- * `actors[0].lanes[].entry` or `execution.timeoutMs`. `humanish migrate` drops each one.
263
+ * `participants[].entry` or `execution.timeoutMs`. `humanish migrate` drops each one.
323
264
  */
324
265
  export function inertFieldPaths(config) {
325
- const routes = routesOf(config);
326
- const paths = [];
327
- for (const [index, actor] of actorsOf(config).entries()) {
328
- for (const row of ACTOR_ROWS)
329
- if (row.applies(actor, routes))
330
- paths.push(`actors[${index}].${row.field}`);
331
- }
332
- for (const row of CONFIG_ROWS)
333
- if (row.applies(config, routes))
334
- paths.push(row.field);
335
- return paths;
266
+ return inertRows(config).map((row) => row.field);
336
267
  }
337
268
  // Report fields that are present but not yet consumed by the engine, so a user never trusts a
338
269
  // setting that silently does nothing.
@@ -1,4 +1,4 @@
1
- import { actorOf, actorsOf, participantList } from "../../study/study-fields.js";
1
+ import { actorOf, capsOf, participantList } from "../../study/study-fields.js";
2
2
  // The runtime image enforces a 30-minute lifetime; reserve setup/teardown room.
3
3
  export const LOCAL_BROWSER_LIFETIME_MS = 30 * 60_000;
4
4
  const MAX_SESSION_MS = 20 * 60_000;
@@ -9,15 +9,16 @@ export function isLocalBrowserStudy(config) {
9
9
  }
10
10
  /** Defaults for the explicitly selected local substrate; hosted configurations are untouched. */
11
11
  export function localBrowserDefaults(config) {
12
- if (!isLocalBrowserStudy(config))
12
+ const actor = actorOf(config);
13
+ if (!isLocalBrowserStudy(config) || actor === undefined)
13
14
  return config;
14
15
  return {
15
16
  ...config,
16
- actors: actorsOf(config).map((actor) => ({
17
+ actor: {
17
18
  ...actor,
18
19
  model: actor.model ?? "gpt-6-astra",
19
20
  reasoningEffort: actor.reasoningEffort ?? "low",
20
- })),
21
+ },
21
22
  execution: {
22
23
  ...config.execution,
23
24
  timeoutMs: config.execution?.timeoutMs ?? MAX_SESSION_MS,
@@ -26,7 +27,7 @@ export function localBrowserDefaults(config) {
26
27
  resolution: config.execution?.desktop?.resolution ?? [960, 720],
27
28
  },
28
29
  },
29
- ...(config.review?.analysis === undefined && actorOf(config)?.type === "local-agent"
30
+ ...(config.review?.analysis === undefined && actor.type === "local-agent"
30
31
  ? { review: { ...config.review, analysis: { provider: "codex" } } }
31
32
  : {}),
32
33
  };
@@ -37,8 +38,7 @@ export function localBrowserUnsupportedReason(config) {
37
38
  if ((config.execution?.timeoutMs ?? MAX_SESSION_MS) > MAX_SESSION_MS) {
38
39
  return "Local browser sessions currently support at most 20 minutes, within the runtime's 30-minute lifetime.";
39
40
  }
40
- if (actorsOf(config).length !== 1 ||
41
- !actor ||
41
+ if (!actor ||
42
42
  (actor.type === "local-agent" && actor.localAgent !== undefined && actor.localAgent !== "codex")) {
43
43
  return "Local browser studies support only Codex local-agent or openai-computer-use participants.";
44
44
  }
@@ -63,10 +63,8 @@ export function localBrowserUnsupportedReason(config) {
63
63
  actor.reasoningEffort !== "low" ||
64
64
  participantList(config)?.some((entry) => entry.reasoningEffort !== undefined && entry.reasoningEffort !== "low") ||
65
65
  actor.maxOutputTokens !== undefined ||
66
- config.execution?.caps?.maxUsd !== undefined ||
67
- config.execution?.caps?.maxTotalUsd !== undefined ||
68
- config.scenario?.caps?.maxUsd !== undefined ||
69
- config.scenario?.caps?.maxTotalUsd !== undefined)) {
66
+ capsOf(config)?.maxUsd !== undefined ||
67
+ capsOf(config)?.maxTotalUsd !== undefined)) {
70
68
  return "Local Codex participants currently use gpt-6-astra at low effort. Account dollar/output-token caps are unavailable; use API participants for those controls.";
71
69
  }
72
70
  for (const target of [
@@ -59,10 +59,19 @@ workflow without leaking private upstream truth into core.
59
59
 
60
60
  ## Study File
61
61
 
62
- Schema: `humanish.study.v3`. `src/study/parse/study-v3.ts` rewrites a v3 document into the
63
- normalized config in `src/study/types.ts`, which `src/study/config.ts` parses. humanish refuses a
64
- `humanish.lab.v2` file with `HUMANISH_STUDY_V2_UNSUPPORTED`, whose message names
65
- `humanish migrate <path>`; migrate converts the file to v3.
62
+ Schema: `humanish.study.v3`. `src/study/config.ts` parses a v3 document into `StudyConfig`
63
+ (`src/study/types.ts`), which has the file's keys; `src/study/parse/front.ts` checks the route and
64
+ the forms of `participants`, `surfaces` and `caps` it takes. humanish refuses a `humanish.lab.v2`
65
+ file with `HUMANISH_STUDY_V2_UNSUPPORTED`, whose message names `humanish migrate <path>`; migrate
66
+ converts the file to v3.
67
+
68
+ The parsed config is the file with defaults filled: a `participants` entry with a `count` expanded
69
+ into its participants, `comms.email.recipients` and a shared world's `execution.concurrency`
70
+ filled, `kind: real` added beside an email `connection`, and a local browser study's model,
71
+ effort, timeout and resolution. `humanish study show --json` prints that config, and `parseStudy`
72
+ reads it back to the same config. Before 0.111.0, `StudyConfig` had the `humanish.lab.v2` keys
73
+ (`actors`, `execution.caps`, the `scenario` object, `subject.topology`); the 0.111.0 release notes
74
+ map each one to its v3 key.
66
75
 
67
76
  A study declares its route and composes code primitives; it is not a hardcoded kind. The
68
77
  top-level keys:
@@ -1552,6 +1561,16 @@ Refusals:
1552
1561
  a preview or scripted study, `createProvider` off computer use, or `prepareDesktop` where no E2B
1553
1562
  desktop exists. The message names the option and the route. The same code refuses a field
1554
1563
  `RunStudyOptions` no longer has (below); that message names the field and where its job went.
1564
+ - `HUMANISH_STUDY_V2_UNSUPPORTED`: the config is `humanish.lab.v2`, or it still sets a
1565
+ `StudyConfig` field of humanish 0.110 that 0.111.0 renamed: `actors`, a `scenario` object,
1566
+ `execution.caps`, `subject.topology`, `personas`, or `count`, `lanes`, `roster` or `laneFocus` on
1567
+ `actor`. The message names each one's v3 field. The
1568
+ planners read only the v3 fields, so a budget left in `execution.caps` would otherwise run
1569
+ uncapped.
1570
+ - `HUMANISH_STUDY_INVALID`: the config has no `actor` object, or its `route` is missing or is not
1571
+ the route its subject and actor take. The message is the one `parseStudy` gives.
1572
+
1573
+ `runStudy` checks the config before the options, so a config refusal comes first.
1555
1574
 
1556
1575
  `rerun.laneIds`, the older name of `rerun.participantIds`, was removed in 0.107.0. A caller that
1557
1576
  still passes it is refused with `HUMANISH_STUDY_OPTION_UNSUPPORTED`; use `rerun.participantIds`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.110.4",
3
+ "version": "0.111.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,38 +0,0 @@
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 {};