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
@@ -4,13 +4,22 @@
4
4
  // a library caller's config itself, under its route's error codes. The planners call the shared
5
5
  // checks in study/validation.ts and desktopCliProductReason below. The other subject rules here have
6
6
  // planner counterparts whose conditions and wording differ (clone serve and repo, local-tree
7
- // target, in-process fan-out, loopback targets, scripted scenario.ref).
7
+ // target, in-process fan-out, loopback targets, scripted `scenario`).
8
8
  import { isLoopbackUrl } from "./parse/subject.js";
9
9
  import { REPO_SLUG_PATTERN } from "./parse/values.js";
10
10
  import { declaredTargets } from "./plan-participants.js";
11
11
  import { actorResolvesToComputerUse, actorResolvesToScriptedBrowser, actorResolvesToTerminal, computerUseParticipantCount, registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, isComputerUseComposition, } from "./routing.js";
12
12
  import { cloneTargetValidationReason, computerUseValidationReason, sharedWorldValidationReason, } from "./validation.js";
13
13
  import { actorOf, participantList, declaredParticipantCount, surfaceCount, modeOf, scenarioRefOf, declaresSharedWorld, } from "./study-fields.js";
14
+ /**
15
+ * The count a scripted or terminal composition reads: its surfaces, or a participant count.
16
+ * parse/front.ts refuses `participants` on route: scripted and route: terminal, and `surfaces` off
17
+ * route: scripted, but a study can declare a route its composition does not take, and these rules
18
+ * run before parseStudy compares the two. migrate's view of a v2 file reaches them the same way.
19
+ */
20
+ function participantOrSurfaceCount(config) {
21
+ return declaredParticipantCount(config) ?? surfaceCount(config);
22
+ }
14
23
  /** The first composition rule a config breaks, in the parser's order, or null. */
15
24
  export function compositionReason(config) {
16
25
  return (thisRepoValidationReason(config) ??
@@ -62,7 +71,7 @@ function localAppValidationReason(config) {
62
71
  return `actor.type must be a registered computer-use actor for local-app subjects (one of: ${registeredComputerUseActors().join(", ")}); the caller's custom executor runs the computer-use loop. Got "${type}".`;
63
72
  }
64
73
  if (computerUseParticipantCount(config) > 1) {
65
- return "Fan-out to more than one participant is not supported on the in-process/local-app route: fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Set participants to 1 and drop participants (use an app-url or clone subject on execution.target: e2b-desktop for fan-out).";
74
+ return "Fan-out to more than one participant is not supported on the in-process/local-app route: fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Remove `participants`, or set `participants: 1`. For fan-out, use an app-url or clone subject on execution.target: e2b-desktop.";
66
75
  }
67
76
  if (participantList(config) !== undefined) {
68
77
  return "`participants` (fan-out roster) is not supported on the in-process/local-app route: it provisions one E2B desktop per participant, which this route skips. Use an app-url or clone subject with execution.target: e2b-desktop.";
@@ -88,7 +97,7 @@ function appUrlValidationReason(config) {
88
97
  if (!scenarioRefOf(config)) {
89
98
  return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
90
99
  }
91
- if ((surfaceCount(config) ?? 1) > 2) {
100
+ if ((participantOrSurfaceCount(config) ?? 1) > 2) {
92
101
  return "A scripted-browser study takes `surfaces` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
93
102
  }
94
103
  if (config.policies?.redactScreenshots === true) {
@@ -109,9 +118,8 @@ function appUrlValidationReason(config) {
109
118
  return `actor.type must be a registered computer-use actor for app-url × e2b-desktop studies (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`;
110
119
  }
111
120
  // Multi-participant fan-out is consumed on this route (`per-lane-worlds`; the shared cua
112
- // cross-validation below enforces `lanes`/`count` XOR rules, the 16 cap, and the
113
- // per-participant target gates, and the allowPublicTargets+N>1 rejection for ambiguous one-target
114
- // fan-out).
121
+ // cross-validation below enforces the 16 cap, the per-participant target gates, and the
122
+ // allowPublicTargets+N>1 rejection for ambiguous one-target fan-out).
115
123
  // Loopback by default; an owner may declare a public/preview target via policies.
116
124
  const targets = [config.subject.appUrl ?? "", ...declaredTargets(config)];
117
125
  const unsafeTarget = targets.find((target) => !config.policies?.allowPublicTargets && !isLoopbackUrl(target));
@@ -142,7 +150,9 @@ function scriptedBrowserValidationReason(config) {
142
150
  if (!REPO_SLUG_PATTERN.test(repo)) {
143
151
  return `subject.repos[0] must be an owner/repo slug (got "${repo}").`;
144
152
  }
145
- if (config.subject.topology !== undefined) {
153
+ // migrate converts a v2 file's `subject.topology: shared-world` to `route: shared-world`, and
154
+ // refuses it here with the v2 parser's message.
155
+ if (declaresSharedWorld(config)) {
146
156
  return "A clone scripted-browser study does not support `subject.topology` yet: it runs one synthetic subject for its scripted actor. Remove `subject.topology`.";
147
157
  }
148
158
  if (config.subject.clone?.fanout !== undefined || config.subject.clone?.keep === true) {
@@ -151,7 +161,7 @@ function scriptedBrowserValidationReason(config) {
151
161
  if (!scenarioRefOf(config)) {
152
162
  return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
153
163
  }
154
- if ((surfaceCount(config) ?? 1) > 2) {
164
+ if ((participantOrSurfaceCount(config) ?? 1) > 2) {
155
165
  return "A scripted-browser study takes `surfaces` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
156
166
  }
157
167
  if (participantList(config) !== undefined) {
@@ -195,8 +205,8 @@ function cloneComputerUseValidationReason(config) {
195
205
  return `subject.repos[0] must be an owner/repo slug (got "${repo}").`;
196
206
  }
197
207
  // Fan-out is consumed here: N participants each clone the same single repo into their own
198
- // E2B desktop (`per-lane-worlds`). The shared cua cross-validation below enforces the
199
- // `lanes`/`count` rules and the 16 cap; the single-repo rule above is unchanged.
208
+ // E2B desktop (`per-lane-worlds`). The shared cua cross-validation below enforces the 16 cap;
209
+ // the single-repo rule above is unchanged.
200
210
  }
201
211
  return null;
202
212
  }
@@ -262,7 +272,7 @@ function terminalValidationReason(config) {
262
272
  if (!actorResolvesToTerminal(type)) {
263
273
  return `actor.type must be a registered terminal actor for terminal-product subjects (one of: ${registeredTerminalActors().join(", ")}). Got "${type}".`;
264
274
  }
265
- if ((declaredParticipantCount(config) ?? 1) > 1) {
275
+ if ((participantOrSurfaceCount(config) ?? 1) > 1) {
266
276
  return "Terminal fan-out to more than one participant is not supported yet; set participants to 1.";
267
277
  }
268
278
  }
@@ -1,22 +1,38 @@
1
- import { type StudyConfig, type StudyParseResult } from "./types.js";
2
- /** The message for a humanish.lab.v2 document, which the package and the CLI no longer run. */
3
- export declare const V2_UNSUPPORTED_MESSAGE = "This is a humanish.lab.v2 study, which humanish no longer reads. Convert its file with humanish migrate <path>.";
1
+ import { type StudyConfig, type StudyParseFailure, type StudyParseResult } from "./types.js";
4
2
  /**
5
3
  * The package's study parser (src/index.ts) and the one discovery uses: a humanish.study.v3
6
4
  * document. It refuses a humanish.lab.v2 one with HUMANISH_STUDY_V2_UNSUPPORTED.
7
5
  */
8
6
  export declare function parseStudy(raw: unknown): StudyParseResult;
7
+ interface StudyV3Options {
8
+ /**
9
+ * `report`, for `humanish migrate`: list the fields the route does not read in `inert`, in place
10
+ * of refusing the study for them. A shared world may then give `participants` as a count or
11
+ * leave it out, as a v2 file could (readStudyFront).
12
+ */
13
+ readonly inert?: "refuse" | "report";
14
+ }
15
+ type StudyV3ParseResult = {
16
+ ok: true;
17
+ config: StudyConfig;
18
+ warnings: string[];
19
+ inert?: string[];
20
+ } | StudyParseFailure;
9
21
  /**
10
- * Validate a parsed YAML object, humanish.study.v3 or humanish.lab.v2, into a StudyConfig. Pure:
11
- * the caller owns file IO. Structural validation only. Fields the engine does not yet consume are
12
- * accepted but reported in `warnings` so `study show` never silently swallows a setting that does
13
- * nothing. The v2 parser's tests call it directly; migrate reads v2 files through migrate/v2.ts.
22
+ * Validate a humanish.study.v3 document into a StudyConfig. Pure: the caller owns file IO. Fields the
23
+ * route does not read refuse the study; migrate's report mode lists them in `inert` instead.
14
24
  */
15
- export declare function parseStudyDocument(raw: unknown): StudyParseResult;
25
+ export declare function parseStudyV3(raw: Record<string, unknown>, options?: StudyV3Options): StudyV3ParseResult;
26
+ /** Why runStudy refuses a config before anything runs. */
27
+ interface ConfigRefusal {
28
+ readonly code: "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID";
29
+ readonly message: string;
30
+ }
16
31
  /**
17
- * The checks that read more than one section of a parsed config, and the defaults they fill:
18
- * shared-world `execution.concurrency`, `comms.email.recipients` and the local browser's defaults.
19
- * parseV2 runs them once every section has parsed. parse/study.ts runs them on its v2 view of a v3
20
- * study.
32
+ * Why runStudy refuses a config a library caller passes, or undefined: a humanish.lab.v2 config,
33
+ * one that still sets a StudyConfig field of humanish 0.110, one with no `actor` object, and one
34
+ * whose `route` is not the route its subject and actor take, with parseStudy's message. A planner
35
+ * reads only the v3 fields, so a budget left in `execution.caps` would otherwise run uncapped.
21
36
  */
22
- export declare function checkStudyConfig(config: StudyConfig): StudyParseResult;
37
+ export declare function libraryConfigRefusal(config: StudyConfig): ConfigRefusal | undefined;
38
+ export {};
@@ -1,204 +1,224 @@
1
- // The normalized config, in the humanish.lab.v2 spelling: a study composes code primitives, with no
2
- // hardcoded study kind.
3
- // A humanish.study.v3 file is rewritten into this spelling first (parse/study-v3.ts), and then must
4
- // take the route it declares and set no field that route does not read.
1
+ // The study parser: a humanish.study.v3 document into a StudyConfig with the file's own keys.
2
+ // parse/front.ts checks what the document declares before its sections parse (the route, the
3
+ // actor object, the participants or surfaces form the route takes, and the caps keys it reads). The
4
+ // sections parse here, checkStudyConfig runs the checks that read more than one section, and the
5
+ // study must then take the route it declares and set no field that route does not read
6
+ // (warnings.ts). A humanish.lab.v2 document is refused; `humanish migrate` reads it
7
+ // (migrate/v2.ts).
5
8
  //
6
- // Scope (read before trusting field names): the engine routes by
7
- // subject.source × execution.target (disambiguated by the actor's run kind where both axes
8
- // collide) and consumes a deliberately small set of fields:
9
- // subject.source/repos/appUrl/serve/env/state/clone.{depth,fanout,keep}, actors[0].count,
10
- // execution.target + execution.desktop.codexAppServer, scenario.mode,
11
- // policies.redactRepos, defaults.open.
12
- // On the computer-use routes (app-url × e2b-desktop, and clone × e2b-desktop with a
13
- // computer-use actor), `actors[0].type` selects the actor: it must resolve to a registered
14
- // computer-use actor, and that descriptor runs the session. Those routes also consume
15
- // actors[0].{mission,persona,laneFocus.instruction,model,reasoningEffort}, execution.timeoutMs,
16
- // execution.desktop.{browser,resolution,sandboxTimeoutMs}, and (clone)
17
- // subject.{serve,env,state,clone.depth}.
18
- // On the scripted-browser route (app-url × local-or-absent, or clone × e2b-desktop, with a
19
- // registered scripted-browser actor), `actors[0].type` selects the actor too, and the route
20
- // consumes scenario.ref (required there, because the committed scenario's browser steps are what the
21
- // actor executes), actors[0].{persona,count}, and execution.timeoutMs. On the provisioned
22
- // clone slice it also consumes subject.{repos,serve,env,state,exposure,clone.depth} and
23
- // execution.desktop.template. actors[0].{mission,laneFocus,model} are inert on that route
24
- // because no model runs, and most execution.desktop.* fields remain forward-declared (device
25
- // presets belong to the computer-use route; scripted surfaces are the driver's own desktop/mobile
26
- // viewports where isMobile/DSF genuinely render via playwright emulation).
27
- // On the other routes those fields remain forward-declared and are not yet consumed:
28
- // parseStudy emits a warning listing any such field that is set, so `study show` shows
29
- // the truth.
9
+ // Scope (read before trusting field names): routeOf derives the route from
10
+ // subject.source × execution.target × the actor's run kind, with `route: shared-world` as the
11
+ // declaration that turns a computer-use composition into a shared world. On the computer-use
12
+ // routes (app-url × e2b-desktop or local, and clone, local-tree or desktop-cli on e2b-desktop with a
13
+ // computer-use actor), `actor.type` must resolve to a registered computer-use actor, and that
14
+ // descriptor runs the session. Those routes consume actor.{mission,persona,model,reasoningEffort},
15
+ // `participants`, `caps`, execution.timeoutMs and execution.desktop.*. The scripted route
16
+ // (app-url × local, or clone × e2b-desktop, with a registered scripted-browser actor) consumes
17
+ // `scenario` (required: its browser steps are what the actor runs), actor.persona and `surfaces`.
18
+ // The terminal route consumes subject.product, `caps` and execution.{terminal,runtimeAuth}.
30
19
  //
31
- // NOTE on actors[0].count: it carries route-specific meanings. Preview route: simCount;
32
- // scripted-browser route: surface roster {1 = desktop, 2 = desktop + mobile}, default 1 (the
33
- // defaults-table single-participant row governs; count: 2 is the declared override); computer-use
34
- // E2B route: the homogeneous fan-out participant count (N identical participants, each its own E2B
35
- // desktop), capped at 16; the in-process/local-app computer-use route stays single-participant (no
36
- // E2B to fan out).
37
- //
38
- // NOTE on actors[0].lanes / actors[0].roster (computer-use E2B route): a
39
- // differentiated fan-out roster: each `{ id?, persona?, device?, instruction?, target? }` becomes one
40
- // independent E2B desktop (`per-lane-worlds`, the default topology). `roster[]` is parser sugar for
41
- // repeated groups and is normalized into `lanes[]` before the engine sees it. `lanes|roster` XOR
42
- // `count` (declare a differentiated roster or a homogeneous count, never both); `lanes|roster`
43
- // XOR `actors[0].laneFocus` (each entry's `instruction` is the roster's steer); `lanes[].device` XOR
44
- // raw `execution.desktop.resolution`. `execution.concurrency` bounds in-flight participants (default:
45
- // the participant count, all at once; env HUMANISH_CUA_MAX_CONCURRENCY may only lower it, per invariant
46
- // 3). On every non-computer-use route normalized `lanes` are inert (warned). subject.clone.fanout
47
- // is rejected on the computer-use route. `lanes[].target` is app-url × computer-use only: an
48
- // absolute browser URL this participant opens instead of `subject.appUrl`; it is the generic
49
- // setup-produced-target handoff, not a service topology primitive.
50
- //
51
- // There is deliberately no v1 compatibility: v1 had zero real users. Breaking schema changes
52
- // bump the version.
20
+ // NOTE on `participants`: a count (preview: the simulated count; computer use: N identical
21
+ // participants, each its own desktop, capped at 16), `{ count, instruction }` (identical
22
+ // participants with one steer), or a list of `{ id?, persona?, device?, instruction?, target? ... }`
23
+ // entries, each its own desktop. A list entry with a `count` is a group that expands into
24
+ // `<id>-01` to `<id>-NN`. A shared world takes a list of at least two. `participants[].device`
25
+ // excludes a raw `execution.desktop.resolution`, and `participants[].target` is app-url computer
26
+ // use only: an absolute browser URL this participant opens in place of `subject.appUrl`.
27
+ // `execution.concurrency` bounds in-flight participants (default: all at once; env
28
+ // HUMANISH_CUA_MAX_CONCURRENCY may only lower it).
53
29
  import { isLocalBrowserStudy, localBrowserDefaults, localBrowserUnsupportedReason, } from "../substrates/local/runtime-config.js";
54
30
  import { isRecord } from "../run/type-guards.js";
55
31
  import { compositionReason } from "./composition-rules.js";
56
- import { findUnknownV2Key } from "./keys.js";
57
- import { parseActors, rosterOf } from "./parse/actors.js";
32
+ import { V2_SCHEMA, V2_UNSUPPORTED_MESSAGE, v2FieldMoves } from "./migrate/v2.js";
33
+ import { parseActorFields, parseActorType, parseParticipantEntries } from "./parse/actors.js";
58
34
  import { parseComms, recipientParticipantId } from "./parse/comms.js";
59
- import { parseDefaults, parseExecution, parsePersonas, parsePolicies, parseReview, parseScenario, } from "./parse/execution.js";
60
- import { studySpelling, studyToV2 } from "./parse/study-v3.js";
35
+ import { parseCaps, parseDefaults, parseExecution, parsePolicies, parseReview, } from "./parse/execution.js";
36
+ import { ACTOR_REQUIRED, readStudyFront, routeNameReason, } from "./parse/front.js";
61
37
  import { parseSubject } from "./parse/subject.js";
62
38
  import { invalid, optionalStr, str } from "./parse/values.js";
63
39
  import { isComputerUseComposition, isSharedWorldComposition, routeOf } from "./routing.js";
64
40
  import { declaredParticipantIds } from "./plan-participants.js";
65
- import { ID_PATTERN, V2_SCHEMA, STUDY_SCHEMA, } from "./types.js";
66
- import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, scenarioCapsValidationReason, smtpValidationReason, taskProtocolValidationReason, } from "./validation.js";
67
- import { forwardDeclaredWarnings, inertFieldLabels } from "./warnings.js";
41
+ import { declaredParticipantCount, participantList } from "./study-fields.js";
42
+ import { ID_PATTERN, STUDY_SCHEMA, } from "./types.js";
43
+ import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, } from "./validation.js";
44
+ import { forwardDeclaredWarnings, inertFieldLabels, inertFieldPaths } from "./warnings.js";
68
45
  import { plural } from "../run/text.js";
69
- /** The message for a humanish.lab.v2 document, which the package and the CLI no longer run. */
70
- export const V2_UNSUPPORTED_MESSAGE = "This is a humanish.lab.v2 study, which humanish no longer reads. Convert its file with humanish migrate <path>.";
71
46
  /**
72
47
  * The package's study parser (src/index.ts) and the one discovery uses: a humanish.study.v3
73
48
  * document. It refuses a humanish.lab.v2 one with HUMANISH_STUDY_V2_UNSUPPORTED.
74
49
  */
75
50
  export function parseStudy(raw) {
76
- if (isRecord(raw) && raw.schema === V2_SCHEMA)
51
+ if (!isRecord(raw))
52
+ return invalid("A study file must be a YAML object.");
53
+ if (raw.schema === V2_SCHEMA)
77
54
  return {
78
55
  ok: false,
79
56
  error: { code: "HUMANISH_STUDY_V2_UNSUPPORTED", message: V2_UNSUPPORTED_MESSAGE },
80
57
  };
81
- return parseStudyDocument(raw);
82
- }
83
- /**
84
- * Validate a parsed YAML object, humanish.study.v3 or humanish.lab.v2, into a StudyConfig. Pure:
85
- * the caller owns file IO. Structural validation only. Fields the engine does not yet consume are
86
- * accepted but reported in `warnings` so `study show` never silently swallows a setting that does
87
- * nothing. The v2 parser's tests call it directly; migrate reads v2 files through migrate/v2.ts.
88
- */
89
- export function parseStudyDocument(raw) {
90
- if (!isRecord(raw)) {
91
- return invalid("A study file must be a YAML object.");
92
- }
93
- if (raw.schema === STUDY_SCHEMA)
94
- return parseV3(raw);
95
- if (raw.schema !== V2_SCHEMA) {
58
+ if (raw.schema !== STUDY_SCHEMA) {
96
59
  return invalid(`The study schema must be ${STUDY_SCHEMA} or ${V2_SCHEMA}.`);
97
60
  }
98
- return parseV2(raw);
61
+ return parseStudyV3(raw);
99
62
  }
100
63
  /**
101
- * A v3 study parses through the v2 parser, then must take the route it declares and set no field
102
- * that route does not read. A v2 file gets those fields as a warning instead.
64
+ * Validate a humanish.study.v3 document into a StudyConfig. Pure: the caller owns file IO. Fields the
65
+ * route does not read refuse the study; migrate's report mode lists them in `inert` instead.
103
66
  */
104
- function parseV3(raw) {
105
- const document = studyToV2(raw);
106
- if (!document.ok)
107
- return document;
108
- const { route, v2, participantSource } = document.value;
109
- const spell = (message) => studySpelling(message, route, participantSource);
110
- const parsed = parseV2(v2);
111
- if (!parsed.ok)
112
- return invalid(spell(parsed.error.message));
113
- const taken = routeOf(parsed.config);
114
- if (taken !== route) {
115
- return invalid(`This study declares route: ${route}, but its subject (source: ${parsed.config.subject.source}) and actor (type: ${parsed.config.actors[0]?.type}) take the ${taken} route. Change \`route\`, or change the subject or actor.`);
67
+ export function parseStudyV3(raw, options = {}) {
68
+ const front = readStudyFront(raw, options.inert === "report");
69
+ if (!front.ok)
70
+ return front;
71
+ const study = parseSections(raw, front.value);
72
+ if (!study.ok)
73
+ return study;
74
+ const checked = checkStudyConfig(study.value);
75
+ if (!checked.ok)
76
+ return checked;
77
+ const { config, warnings } = checked;
78
+ const { route } = config;
79
+ const mismatch = routeMismatchReason(config);
80
+ if (mismatch)
81
+ return invalid(mismatch);
82
+ if (options.inert === "report") {
83
+ return { ok: true, config, warnings, inert: inertFieldPaths(config) };
116
84
  }
117
- const inert = inertFieldLabels(parsed.config);
85
+ const inert = inertFieldLabels(config);
118
86
  if (inert.length > 0) {
119
- return invalid(spell(`route: ${route} does not read ${inert.join(", ")}. Remove ${inert.length === 1 ? "it" : "them"}.`));
87
+ return invalid(`route: ${route} does not read ${inert.join(", ")}. Remove ${inert.length === 1 ? "it" : "them"}.`);
120
88
  }
121
- return {
122
- ok: true,
123
- config: { ...parsed.config, schema: STUDY_SCHEMA },
124
- warnings: parsed.warnings.map(spell),
125
- };
89
+ return { ok: true, config, warnings };
90
+ }
91
+ /** Why a config's declared route is not the one its subject and actor take, or undefined. */
92
+ function routeMismatchReason(config) {
93
+ const taken = routeOf(config);
94
+ if (taken === config.route)
95
+ return undefined;
96
+ return `This study declares route: ${config.route}, but its subject (source: ${config.subject.source}) and actor (type: ${config.actor.type}) take the ${taken} route. Change \`route\`, or change the subject or actor.`;
97
+ }
98
+ /**
99
+ * Why runStudy refuses a config a library caller passes, or undefined: a humanish.lab.v2 config,
100
+ * one that still sets a StudyConfig field of humanish 0.110, one with no `actor` object, and one
101
+ * whose `route` is not the route its subject and actor take, with parseStudy's message. A planner
102
+ * reads only the v3 fields, so a budget left in `execution.caps` would otherwise run uncapped.
103
+ */
104
+ export function libraryConfigRefusal(config) {
105
+ const raw = config;
106
+ if (!isRecord(raw))
107
+ return undefined;
108
+ if (raw.schema === V2_SCHEMA)
109
+ return { code: "HUMANISH_STUDY_V2_UNSUPPORTED", message: V2_UNSUPPORTED_MESSAGE };
110
+ const moves = v2FieldMoves(raw);
111
+ if (moves.length > 0)
112
+ return {
113
+ code: "HUMANISH_STUDY_V2_UNSUPPORTED",
114
+ message: `This StudyConfig sets fields that humanish 0.111.0 renamed: ${moves.join("; ")}. Set the humanish.study.v3 fields, or parse the study file with parseStudy. The 0.111.0 release notes list each renamed field.`,
115
+ };
116
+ if (!isRecord(raw.actor))
117
+ return { code: "HUMANISH_STUDY_INVALID", message: ACTOR_REQUIRED };
118
+ const message = routeNameReason(raw.route) ?? routeMismatchReason(config);
119
+ return message === undefined ? undefined : { code: "HUMANISH_STUDY_INVALID", message };
126
120
  }
127
- function parseV2(raw) {
128
- const unknownKey = findUnknownV2Key(raw);
129
- if (unknownKey)
130
- return invalid(unknownKey);
121
+ // Each section in a fixed order, so a file with more than one error always reports the same one
122
+ // first. migrate's checkV2 reads a v2 file's sections in this order too.
123
+ function parseSections(raw, front) {
131
124
  const id = str(raw.id);
132
125
  if (!id || !ID_PATTERN.test(id)) {
133
126
  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_.-]*$/).");
134
127
  }
135
- const subjectResult = parseSubject(raw.subject);
136
- if (!subjectResult.ok) {
137
- return subjectResult;
138
- }
139
- const actorsResult = parseActors(raw.actors);
140
- if (!actorsResult.ok) {
141
- return actorsResult;
142
- }
143
- const executionResult = parseExecution(raw.execution);
144
- if (!executionResult.ok) {
145
- return executionResult;
146
- }
147
- const config = {
148
- schema: V2_SCHEMA,
149
- id,
150
- ...optionalStr("title", raw.title),
151
- ...optionalStr("description", raw.description),
152
- subject: subjectResult.value,
153
- actors: actorsResult.value,
154
- ...(executionResult.value ? { execution: executionResult.value } : {}),
155
- };
156
- const personas = parsePersonas(raw.personas);
157
- if (personas)
158
- config.personas = personas;
159
- const scenarioResult = parseScenario(raw.scenario);
160
- if (!scenarioResult.ok) {
161
- return scenarioResult;
162
- }
163
- if (scenarioResult.value)
164
- config.scenario = scenarioResult.value;
128
+ const subject = parseSubject(raw.subject);
129
+ if (!subject.ok)
130
+ return subject;
131
+ const type = parseActorType(front.actor, "actor");
132
+ if (!type.ok)
133
+ return type;
134
+ const participants = parseParticipants(raw.participants, front.participants);
135
+ if (!participants.ok)
136
+ return participants;
137
+ const fields = parseActorFields(front.actor, "actor");
138
+ if (!fields.ok)
139
+ return fields;
140
+ const execution = parseExecution(raw.execution);
141
+ if (!execution.ok)
142
+ return execution;
143
+ const caps = parseCaps(raw.caps);
144
+ if (!caps.ok)
145
+ return caps;
165
146
  if (isRecord(raw.policies) &&
166
147
  raw.policies.mediaPermission !== undefined &&
167
148
  raw.policies.mediaPermission !== "prompt" &&
168
149
  raw.policies.mediaPermission !== "granted") {
169
150
  return invalid("`policies.mediaPermission` must be `prompt` (the participant answers the browser's own dialog) or `granted`.");
170
151
  }
171
- const policiesResult = parsePolicies(raw.policies);
172
- if (!policiesResult.ok)
173
- return policiesResult;
174
- if (policiesResult.value)
175
- config.policies = policiesResult.value;
176
- const reviewResult = parseReview(raw.review);
177
- if (!reviewResult.ok)
178
- return reviewResult;
179
- if (reviewResult.value)
180
- config.review = reviewResult.value;
181
- if (raw.defaults !== undefined && isRecord(raw.defaults) && raw.defaults.open !== undefined) {
152
+ const policies = parsePolicies(raw.policies);
153
+ if (!policies.ok)
154
+ return policies;
155
+ const review = parseReview(raw.review);
156
+ if (!review.ok)
157
+ return review;
158
+ if (isRecord(raw.defaults) && raw.defaults.open !== undefined) {
182
159
  if (typeof raw.defaults.open !== "boolean")
183
160
  return invalid("`defaults.open` must be true or false.");
184
161
  }
185
162
  const defaults = parseDefaults(raw.defaults);
186
- if (defaults)
187
- config.defaults = defaults;
188
- const commsResult = parseComms(raw.comms);
189
- if (!commsResult.ok)
190
- return commsResult;
191
- if (commsResult.value)
192
- config.comms = commsResult.value;
193
- return checkStudyConfig(config);
163
+ const comms = parseComms(raw.comms);
164
+ if (!comms.ok)
165
+ return comms;
166
+ const mode = raw.mode === "dry-run" || raw.mode === "live" ? raw.mode : undefined;
167
+ const { surfaces } = front;
168
+ const scenario = str(raw.scenario);
169
+ return {
170
+ ok: true,
171
+ value: {
172
+ schema: STUDY_SCHEMA,
173
+ id,
174
+ ...optionalStr("title", raw.title),
175
+ ...optionalStr("description", raw.description),
176
+ route: front.route,
177
+ ...(mode === undefined ? {} : { mode }),
178
+ subject: subject.value,
179
+ actor: { type: type.value, ...fields.value },
180
+ ...(participants.value === undefined ? {} : { participants: participants.value }),
181
+ ...(surfaces === undefined ? {} : { surfaces }),
182
+ ...(caps.value === undefined ? {} : { caps: caps.value }),
183
+ ...(execution.value === undefined ? {} : { execution: execution.value }),
184
+ ...(scenario === undefined ? {} : { scenario }),
185
+ ...(policies.value === undefined ? {} : { policies: policies.value }),
186
+ ...(review.value === undefined ? {} : { review: review.value }),
187
+ ...(defaults === undefined ? {} : { defaults }),
188
+ ...(comms.value === undefined ? {} : { comms: comms.value }),
189
+ },
190
+ };
191
+ }
192
+ // `participants` in the form the file wrote it. readStudyFront has checked the form against the
193
+ // route and expanded each list entry that has a count; the entries parse here, each named by the
194
+ // `participants` entry it came from. An instruction that is not a non-empty string is dropped, as
195
+ // the v2 parser drops it.
196
+ function parseParticipants(raw, read) {
197
+ if (read.entries !== undefined) {
198
+ const { source } = read;
199
+ return parseParticipantEntries(read.entries, {
200
+ list: "participants",
201
+ entry: (index) => `participants[${source[index] ?? index}]`,
202
+ });
203
+ }
204
+ if (typeof raw === "number")
205
+ return { ok: true, value: read.count };
206
+ if (!isRecord(raw))
207
+ return { ok: true, value: undefined };
208
+ const instruction = str(read.instruction);
209
+ return {
210
+ ok: true,
211
+ value: {
212
+ ...(read.count === undefined ? {} : { count: read.count }),
213
+ ...(instruction === undefined ? {} : { instruction }),
214
+ },
215
+ };
194
216
  }
195
217
  /**
196
218
  * The checks that read more than one section of a parsed config, and the defaults they fill:
197
219
  * shared-world `execution.concurrency`, `comms.email.recipients` and the local browser's defaults.
198
- * parseV2 runs them once every section has parsed. parse/study.ts runs them on its v2 view of a v3
199
- * study.
200
220
  */
201
- export function checkStudyConfig(config) {
221
+ function checkStudyConfig(config) {
202
222
  const smtpReason = smtpValidationReason(config);
203
223
  if (smtpReason)
204
224
  return invalid(smtpReason);
@@ -208,9 +228,6 @@ export function checkStudyConfig(config) {
208
228
  const outputLimitReason = outputTokenLimitValidationReason(config);
209
229
  if (outputLimitReason)
210
230
  return invalid(outputLimitReason);
211
- const scenarioCapsReason = scenarioCapsValidationReason(config);
212
- if (scenarioCapsReason)
213
- return invalid(scenarioCapsReason);
214
231
  // All-parallel default: a multi-participant study that does not declare execution.concurrency
215
232
  // runs every participant at once; the declared field is a cap the author chose, never a mode.
216
233
  // Independent computer-use participants resolve that default from the final participant count
@@ -218,7 +235,7 @@ export function checkStudyConfig(config) {
218
235
  // world's roster is fixed, so its default is filled here for the envelopes and warnings that read
219
236
  // the parsed config.
220
237
  {
221
- const participantCount = rosterOf(config.actors[0])?.length ?? config.actors[0]?.count ?? 1;
238
+ const participantCount = participantList(config)?.length ?? declaredParticipantCount(config) ?? 1;
222
239
  if (participantCount > 1 &&
223
240
  config.execution?.concurrency === undefined &&
224
241
  isSharedWorldComposition(config)) {
@@ -1,5 +1,5 @@
1
1
  import type { ActorPersonaRef } from "../actors/contract.js";
2
- import { type StudyConfig } from "./types.js";
2
+ import type { StudyConfig } from "./types.js";
3
3
  declare const STUDY_LIST_SCHEMA = "humanish.study-list.v1";
4
4
  declare const STUDY_SHOW_SCHEMA = "humanish.study-show.v1";
5
5
  type StudyOrigin = "committed" | "ignored" | "explicit";
@@ -5,7 +5,7 @@ import { lstat, open, readdir, realpath } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { parse } from "yaml";
7
7
  import { parseStudy } from "./config.js";
8
- import { V2_SCHEMA } from "./types.js";
8
+ import { V2_SCHEMA } from "./migrate/v2.js";
9
9
  import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
10
10
  import { isNodeError } from "../run/type-guards.js";
11
11
  import { studyFileCandidates, STUDY_DIRECTORIES } from "./files.js";