humanish 0.109.0 → 0.109.1

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,15 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.109.1: Current option names in messages, reclaim --env-file (2026-10-03)
12
+
13
+ humanish 0.109.1 is a patch. Library refusals and warnings name `RunStudyOptions`, which 0.109.0
14
+ exports, where they named the removed `RunLabOptions`. `humanish export --format bundle` no longer
15
+ copies sandbox ids into its redacted workspace. `humanish reclaim` takes `--env-file`, and
16
+ `humanish migrate` removes a `labs/` directory that its moves emptied.
17
+
18
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.109.1)
19
+
11
20
  ## 0.109.0: Lab names removed, v2 study files refused (2026-10-03)
12
21
 
13
22
  humanish 0.109.0 removes what 0.108.0 deprecated. A `humanish.lab.v2` file, or any study file in a
@@ -7,7 +7,7 @@ interface StudyAnalysisSettings {
7
7
  question?: string;
8
8
  timeoutMs?: number;
9
9
  }
10
- /** The study's review.analysis: a provider selection for a separate review after a live study. */
10
+ /** The study's review.analysis: a provider selection for a separate review after a live run. */
11
11
  export type StudyAnalysis = StudyAnalysisSettings & ({
12
12
  provider?: "openai";
13
13
  maxCostUsd: number;
@@ -4,8 +4,11 @@ function formatValue(value) {
4
4
  const text = JSON.stringify(value);
5
5
  return text.length > 80 ? `${text.slice(0, 77)}...` : text;
6
6
  }
7
- /** Each file's source, destination, moved keys and dropped keys, printed before any write. */
8
- function formatMigratePlan(files) {
7
+ /**
8
+ * Each file's source, destination, moved keys and dropped keys, and each labs/ directory the moves
9
+ * empty, printed before any write.
10
+ */
11
+ function formatMigratePlan(files, emptied) {
9
12
  const lines = [];
10
13
  for (const file of files) {
11
14
  if (file.action === "skip") {
@@ -22,6 +25,9 @@ function formatMigratePlan(files) {
22
25
  lines.push(` its comment: ${key.comments.replace(/\n/g, " ")}`);
23
26
  }
24
27
  }
28
+ for (const directory of emptied) {
29
+ lines.push(`${directory}/: removed after the move, since nothing else is in it`);
30
+ }
25
31
  return lines.length > 0 ? `${lines.join("\n")}\n` : "No study files to convert.\n";
26
32
  }
27
33
  function formatMigrateOutcome(result) {
@@ -33,7 +39,12 @@ function formatMigrateOutcome(result) {
33
39
  const files = written === 1 ? "1 study file" : `${written} study files`;
34
40
  if (result.dryRun)
35
41
  return `Dry run: nothing was written. ${files} would change.\n`;
36
- return written === 0 ? "No v2 study files to convert.\n" : `Converted ${files}.\n`;
42
+ if (written === 0)
43
+ return "No v2 study files to convert.\n";
44
+ const removed = result.removedDirectories ?? [];
45
+ return removed.length === 0
46
+ ? `Converted ${files}.\n`
47
+ : `Converted ${files}. Removed ${removed.map((directory) => `${directory}/`).join(", ")}, which the move emptied.\n`;
37
48
  }
38
49
  /** `humanish migrate`. */
39
50
  export function registerMigrateCommand(parent, io) {
@@ -57,7 +68,7 @@ export function registerMigrateCommand(parent, io) {
57
68
  dryRun: options.dryRun === true,
58
69
  // The plan is printed before anything is written; with --json it goes to stderr, so stdout
59
70
  // stays one JSON document.
60
- onPlan: (files) => (json ? io.writeErr : io.writeOut)(formatMigratePlan(files)),
71
+ onPlan: (files, emptied) => (json ? io.writeErr : io.writeOut)(formatMigratePlan(files, emptied)),
61
72
  });
62
73
  writeResult(command, io, result, formatMigrateOutcome);
63
74
  io.setExitCode(result.ok ? 0 : 2);
@@ -7,7 +7,7 @@ import { cleanupRun, listRuns, readReview } from "../../run/stored-runs.js";
7
7
  import { verifyRun } from "../../verify/verify.js";
8
8
  import { reclaimPreflightSandboxes, reclaimRunSandboxes, } from "../../run/reclaim.js";
9
9
  import { addRunOptions, handleRun } from "./run-command.js";
10
- import { CWD_OPTION_DESCRIPTION, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
10
+ import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, ENV_FILE_OPTION_DESCRIPTION, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
11
11
  import { plural } from "../../run/text.js";
12
12
  export function registerRunCommand(parent, io) {
13
13
  addRunOptions(parent
@@ -165,13 +165,17 @@ export function registerRuntimeCommands(parent, io) {
165
165
  export function registerReclaimCommand(parent, io) {
166
166
  parent
167
167
  .command("reclaim")
168
- .description("Kill an interrupted run's sandboxes by the exact ids journaled in its sandbox-receipts.ndjson; never enumerates the E2B account. Needs E2B_API_KEY in the environment.")
168
+ .description("Kill an interrupted run's sandboxes by the exact ids journaled in its sandbox-receipts.ndjson; never enumerates the E2B account. Needs E2B_API_KEY in the environment or in --env-file.")
169
169
  .summary("Stop an interrupted run's leftover sandboxes.")
170
170
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
171
171
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
172
172
  .addOption(new Option("--preflight", "Reclaim sandboxes left by interrupted `humanish study check` probes (journaled in .humanish/preflight) instead of a run's.").conflicts("run"))
173
+ .option("--env-file <path>", ENV_FILE_OPTION_DESCRIPTION)
173
174
  .option("--json", JSON_OPTION_DESCRIPTION)
174
175
  .action(async (options, command) => {
176
+ // The kill calls read E2B_API_KEY from the environment: the env file, then discovered keys.
177
+ if (!(await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io })))
178
+ return;
175
179
  const result = options.preflight
176
180
  ? await reclaimPreflightSandboxes(options.cwd)
177
181
  : await reclaimRunSandboxes(options.cwd, options.run);
@@ -2,6 +2,17 @@
2
2
  export declare function sensitivePatterns(): readonly RegExp[];
3
3
  /** True if the text contains any secret-shaped token or known local path. */
4
4
  export declare function containsSensitive(text: string): boolean;
5
+ /**
6
+ * What a shared copy of a run says in place of a provider sandbox id. scripts/public-surface-scan.mjs
7
+ * passes only this value at a sandbox-id key.
8
+ */
9
+ export declare const REDACTED_SANDBOX_ID = "[redacted-sandbox-id]";
10
+ /**
11
+ * `value` with every sandbox id replaced by REDACTED_SANDBOX_ID: strings at `sandboxId` and
12
+ * `subjectSandboxId`, and the `id` of each `providerResources` entry. A run keeps raw ids as local
13
+ * evidence for cleanup; a copy made to be shared names no sandbox.
14
+ */
15
+ export declare function redactSandboxIds(value: unknown): unknown;
5
16
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
6
17
  export declare function redactText(text: string): string;
7
18
  /** Redact every sensitive match (secrets and paths) to a single [REDACTED_SECRET] label. */
@@ -55,6 +55,49 @@ export function containsSensitive(text) {
55
55
  function redactLocalPaths(text, label) {
56
56
  return LOCAL_PATH_PATTERNS.reduce((current, [pattern, replacement]) => current.replace(pattern, (match) => (label ?? replacement) + (match.match(/\\+$/)?.[0] ?? "")), text);
57
57
  }
58
+ /**
59
+ * What a shared copy of a run says in place of a provider sandbox id. scripts/public-surface-scan.mjs
60
+ * passes only this value at a sandbox-id key.
61
+ */
62
+ export const REDACTED_SANDBOX_ID = "[redacted-sandbox-id]";
63
+ /** The keys run writers put a sandbox id under; `providerResources` entries hold one as `id`. */
64
+ const SANDBOX_ID_KEYS = new Set(["sandboxId", "subjectSandboxId"]);
65
+ /**
66
+ * `value` with every sandbox id replaced by REDACTED_SANDBOX_ID: strings at `sandboxId` and
67
+ * `subjectSandboxId`, and the `id` of each `providerResources` entry. A run keeps raw ids as local
68
+ * evidence for cleanup; a copy made to be shared names no sandbox.
69
+ */
70
+ export function redactSandboxIds(value) {
71
+ if (Array.isArray(value)) {
72
+ const items = value.map(redactSandboxIds);
73
+ return items.every((item, index) => item === value[index]) ? value : items;
74
+ }
75
+ if (value === null || typeof value !== "object")
76
+ return value;
77
+ let changed = false;
78
+ // fromEntries keeps a key such as __proto__ an own property, as JSON.parse made it.
79
+ const entries = Object.entries(value).map(([key, child]) => {
80
+ let next;
81
+ if (SANDBOX_ID_KEYS.has(key) && typeof child === "string")
82
+ next = REDACTED_SANDBOX_ID;
83
+ else if (key === "providerResources" && Array.isArray(child))
84
+ next = child.map((resource) => resource !== null &&
85
+ typeof resource === "object" &&
86
+ !Array.isArray(resource) &&
87
+ "id" in resource
88
+ ? Object.fromEntries(Object.entries(redactSandboxIds(resource)).map(([field, entry]) => [
89
+ field,
90
+ field === "id" ? REDACTED_SANDBOX_ID : entry,
91
+ ]))
92
+ : redactSandboxIds(resource));
93
+ else
94
+ next = redactSandboxIds(child);
95
+ if (next !== child && JSON.stringify(next) !== JSON.stringify(child))
96
+ changed = true;
97
+ return [key, next];
98
+ });
99
+ return changed ? Object.fromEntries(entries) : value;
100
+ }
58
101
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
59
102
  export function redactText(text) {
60
103
  const withoutSecrets = SECRET_PATTERNS.reduce((current, pattern) => current.replace(pattern, "[REDACTED_SECRET]"), text);
@@ -8,7 +8,7 @@ import { parse as parseYaml } from "yaml";
8
8
  import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
9
9
  import { renderObserver } from "../observer/render.js";
10
10
  import { buildObserverData } from "../observer/data.js";
11
- import { containsSensitive, redactScreenshot, redactText } from "../evidence/redaction.js";
11
+ import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
12
12
  import { decodeEscapes } from "../evidence/encoded-text.js";
13
13
  import { readPlainText } from "../evidence/plain-text.js";
14
14
  import { streamScreenshotPaths } from "../verify/artifacts.js";
@@ -224,7 +224,8 @@ function transformText(file, imagePaths) {
224
224
  const parsed = JSON.parse(text);
225
225
  // Parsing also resolves escape sequences in string values before inspection.
226
226
  assertNoInlineRaster(JSON.stringify(parsed));
227
- const transformed = updateScreenshotDeclarations(parsed, imagePaths);
227
+ // The copy names no sandbox: an id is local operational evidence, and the copy is shared.
228
+ const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
228
229
  if (JSON.stringify(transformed) === JSON.stringify(parsed))
229
230
  return file.bytes;
230
231
  assertRewritableNumbers(text);
@@ -237,7 +238,7 @@ function transformText(file, imagePaths) {
237
238
  return line;
238
239
  const parsed = JSON.parse(line);
239
240
  assertNoInlineRaster(JSON.stringify(parsed));
240
- const updated = JSON.stringify(updateScreenshotDeclarations(parsed, imagePaths));
241
+ const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
241
242
  if (updated === JSON.stringify(parsed))
242
243
  return line;
243
244
  assertRewritableNumbers(line);
@@ -329,7 +330,7 @@ async function writeDerivative(source, stagePaths, bundle) {
329
330
  bytes = transformText(file, imagePaths);
330
331
  if (file.path === "run.json") {
331
332
  assertRewritableNumbers(file.bytes.toString("utf8"));
332
- const transformed = updateScreenshotDeclarations(bundle, imagePaths);
333
+ const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
333
334
  transformed.redaction = {
334
335
  status: "passed",
335
336
  notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
@@ -20,7 +20,7 @@ export function createInProcessDesktop(deps) {
20
20
  throw new Error("The participant desktop must be prepared and may only be opened once, before finalization.");
21
21
  opened = true;
22
22
  if (deps.inProcessExecutor === undefined)
23
- throw new Error("The in-process route needs RunLabOptions.inProcess.");
23
+ throw new Error("The in-process route needs RunStudyOptions.inProcess.");
24
24
  return { executor: await deps.inProcessExecutor(deps.appUrl) };
25
25
  },
26
26
  finalize() {
@@ -159,7 +159,7 @@ function driverReason(driving, { localAppSubject, inProcessRoute }) {
159
159
  if (driving.inProcess && !driving.createProvider)
160
160
  return {
161
161
  code: "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER",
162
- message: "RunLabOptions.inProcess needs RunLabOptions.createProvider: an in-process executor returns no screenshot, so it needs a provider that does not read images. The default OpenAI computer-use provider reads screenshots and would stop the session.",
162
+ message: "RunStudyOptions.inProcess needs RunStudyOptions.createProvider: an in-process executor returns no screenshot, so it needs a provider that does not read images. The default OpenAI computer-use provider reads screenshots and would stop the session.",
163
163
  };
164
164
  // There is no built-in in-process driver for a local app.
165
165
  if (localAppSubject && !inProcessRoute)
@@ -233,7 +233,7 @@ export function planComputerUseStudy(config, input) {
233
233
  const source = config.subject.source;
234
234
  if (driving.inProcess &&
235
235
  (source === "clone" || source === "local-tree" || source === "desktop-cli"))
236
- return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", `RunLabOptions.inProcess drives subject.appUrl in this process, and a ${source} subject needs the hosted desktop the in-process route never creates. Use an app-url or local-app subject with inProcess, or remove inProcess to run on a hosted desktop.`, actor);
236
+ return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", `RunStudyOptions.inProcess drives subject.appUrl in this process, and a ${source} subject needs the hosted desktop the in-process route never creates. Use an app-url or local-app subject with inProcess, or remove inProcess to run on a hosted desktop.`, actor);
237
237
  // The parser's rule; without a product the desktop study fails later.
238
238
  const productReason = desktopCliProductReason(config);
239
239
  if (productReason)
@@ -243,7 +243,7 @@ export function planComputerUseStudy(config, input) {
243
243
  return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", `Computer-use fan-out is capped at ${MAX_COMPUTER_USE_PARTICIPANTS} participants (resolved ${participants.length}); N concurrent paid desktops is real spend.`, actor);
244
244
  const [first, ...rest] = participants;
245
245
  if (driving.inProcess && rest.length > 0)
246
- return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", "Fan-out to more than one participant is not supported on the in-process route (RunLabOptions.inProcess): fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Run a single in-process participant, or fan out on the E2B route.", actor);
246
+ return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", "Fan-out to more than one participant is not supported on the in-process route (RunStudyOptions.inProcess): fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Run a single in-process participant, or fan out on the E2B route.", actor);
247
247
  if (first === undefined)
248
248
  throw new Error("computerUseParticipants returned no participant");
249
249
  const appUrl = config.subject.appUrl ?? "";
@@ -89,7 +89,7 @@ export async function admitCuaRun(plan, input, config) {
89
89
  ok: false,
90
90
  result: fail(...args),
91
91
  });
92
- // runStudyWith's local VM study supplies a local-target app-url study's desktop; a direct route call or
92
+ // runStudyWith's `localVm` supplies the desktop of a local-target app-url study; a direct route call or
93
93
  // an in-process executor on the same study has none. It reads the declared source, as the planner
94
94
  // did: a library config the parser never saw can plan to an app-url subject from another source.
95
95
  if (config.subject.source === "app-url" &&
@@ -21,6 +21,8 @@ export interface MigrateResult {
21
21
  readonly files: readonly MigrateFile[];
22
22
  /** Files left behind after a clean-up step found them changed: sources, backups or temp files. */
23
23
  readonly unresolved?: readonly string[];
24
+ /** The labs/ directories the moves emptied and migrate removed, or with `--dry-run` would remove. */
25
+ readonly removedDirectories?: readonly string[];
24
26
  readonly error?: {
25
27
  readonly code: "HUMANISH_MIGRATE_REFUSED" | "HUMANISH_MIGRATE_FAILED";
26
28
  readonly phase: "plan" | "stage" | "commit" | "clean-up";
@@ -34,8 +36,11 @@ export interface MigrateOptions {
34
36
  /** Files to convert. Without any, the six study directories are scanned. */
35
37
  readonly paths?: readonly string[];
36
38
  readonly dryRun?: boolean;
37
- /** Called with the plan before anything is written, so a caller can print every destination. */
38
- readonly onPlan?: (files: readonly MigrateFile[]) => void;
39
+ /**
40
+ * Called with the plan before anything is written, so a caller can print every destination and
41
+ * each labs/ directory the moves empty, which migrate then removes.
42
+ */
43
+ readonly onPlan?: (files: readonly MigrateFile[], emptied: readonly string[]) => void;
39
44
  /** Replaces `fs.link`. Tests use it to stand in for a filesystem without hard links. */
40
45
  readonly link?: (from: string, to: string) => Promise<void>;
41
46
  /** Called between stage and commit, and between commit and clean-up. Tests change files there. */
@@ -8,6 +8,8 @@
8
8
  // name, so no file this run did not write is ever overwritten. A failure undoes the commits.
9
9
  // 4. Clean up: delete each moved source and each rewrite's original, once checked (owned-files.ts).
10
10
  // A file that changed since the plan is kept and listed; a rewrite's original as <name>.v2.bak.
11
+ // Then a labs/ directory whose every entry moved is removed with a plain rmdir, which fails, and
12
+ // keeps the directory, when anything else is in it by then.
11
13
  import { randomUUID } from "node:crypto";
12
14
  import { link, lstat, readdir, readFile, realpath, rename, rmdir } from "node:fs/promises";
13
15
  import path from "node:path";
@@ -317,6 +319,46 @@ async function undoCommits(root, writes, linkFile) {
317
319
  }
318
320
  return unrestored;
319
321
  }
322
+ // Each labs/ directory whose every entry, a dotfile or a subdirectory included, is a file this run
323
+ // moves out. Only these are removed after the move.
324
+ async function emptiedRetiredDirectories(root, writes) {
325
+ const moving = new Map();
326
+ for (const write of writes) {
327
+ if (write.file.action !== "move")
328
+ continue;
329
+ const directory = path.dirname(write.sourcePath);
330
+ const relative = path.relative(root, directory);
331
+ const retired = STUDY_DIRECTORIES.some((entry) => entry.family === "labs" && entry.relativeDir === relative);
332
+ if (!retired)
333
+ continue;
334
+ const entry = moving.get(directory) ?? { write, names: new Set() };
335
+ entry.names.add(path.basename(write.sourcePath));
336
+ moving.set(directory, entry);
337
+ }
338
+ const emptied = [];
339
+ for (const [directory, { write, names }] of moving) {
340
+ const entries = await readdir(directory).catch(() => undefined);
341
+ if (entries !== undefined && entries.every((name) => names.has(name)))
342
+ emptied.push(write);
343
+ }
344
+ return emptied;
345
+ }
346
+ // Remove each emptied labs/ directory, if it is still the one the plan read. rmdir is not
347
+ // recursive, so a file that appeared in it since the plan keeps it. Returns the removed paths.
348
+ async function removeEmptiedDirectories(root, emptied) {
349
+ const removed = [];
350
+ for (const write of emptied) {
351
+ try {
352
+ await assertDirectory(write.sourceDirectory, root);
353
+ await rmdir(path.dirname(write.sourcePath));
354
+ removed.push(path.relative(root, path.dirname(write.sourcePath)));
355
+ }
356
+ catch {
357
+ // Not empty, or not the directory the plan read: it stays.
358
+ }
359
+ }
360
+ return removed;
361
+ }
320
362
  // Delete each moved source and each rewrite's original, but only once the v3 file is in place as
321
363
  // written, and only if the old file is still the one the plan read (removeChecked). Anything kept
322
364
  // is listed; a rewrite's original is kept as <name>.v2.bak.
@@ -356,9 +398,17 @@ export async function migrateStudies(options) {
356
398
  const linkFile = options.link ?? link;
357
399
  try {
358
400
  ({ files, writes } = await planWrites(root, options));
359
- options.onPlan?.(files);
360
- if (options.dryRun === true || writes.length === 0)
361
- return { ...base, ok: true, files };
401
+ const emptied = await emptiedRetiredDirectories(root, writes);
402
+ const emptiedPaths = emptied.map((write) => path.relative(root, path.dirname(write.sourcePath)));
403
+ options.onPlan?.(files, emptiedPaths);
404
+ if (options.dryRun === true || writes.length === 0) {
405
+ return {
406
+ ...base,
407
+ ok: true,
408
+ files,
409
+ ...(emptiedPaths.length === 0 ? {} : { removedDirectories: emptiedPaths }),
410
+ };
411
+ }
362
412
  try {
363
413
  await stage(root, writes, created);
364
414
  options.onPhase?.("stage");
@@ -386,7 +436,13 @@ export async function migrateStudies(options) {
386
436
  },
387
437
  };
388
438
  }
389
- return { ...base, ok: true, files };
439
+ const removed = await removeEmptiedDirectories(root, emptied);
440
+ return {
441
+ ...base,
442
+ ok: true,
443
+ files,
444
+ ...(removed.length === 0 ? {} : { removedDirectories: removed }),
445
+ };
390
446
  }
391
447
  catch (error) {
392
448
  const failure = error instanceof MigrateError
@@ -43,7 +43,7 @@ export function studyEventEmitter(onEvent, warnings, secretValues) {
43
43
  catch {
44
44
  detail = "the thrown value has no message";
45
45
  }
46
- warnings.push(`RunLabOptions.onEvent failed on ${type}: ${detail}`);
46
+ warnings.push(`RunStudyOptions.onEvent failed on ${type}: ${detail}`);
47
47
  };
48
48
  try {
49
49
  const returned = onEvent(event);
@@ -11,7 +11,7 @@ import { studyResultIdentity } from "../run/study-result.js";
11
11
  const unsupported = (option, route, reason) => ({
12
12
  ok: false,
13
13
  code: "HUMANISH_STUDY_OPTION_UNSUPPORTED",
14
- message: `RunLabOptions.${option} is not supported on the ${route} route: ${reason}`,
14
+ message: `RunStudyOptions.${option} is not supported on the ${route} route: ${reason}`,
15
15
  });
16
16
  // Fields RunStudyOptions no longer has, and where each one's job went.
17
17
  const REMOVED_OPTIONS = {
@@ -32,7 +32,7 @@ export function removedOptionRefusal(options) {
32
32
  return {
33
33
  ok: false,
34
34
  code: "HUMANISH_STUDY_OPTION_UNSUPPORTED",
35
- message: 'RunLabOptions.rerun.laneIds was removed. Use rerun.participantIds. See docs/contracts/schemas.md, "Library options".',
35
+ message: '`rerun.laneIds` was removed in 0.107.0. Use `rerun.participantIds`. See docs/contracts/schemas.md, "Library options".',
36
36
  };
37
37
  const field = Object.keys(REMOVED_OPTIONS).find((key) => Reflect.get(options, key) !== undefined);
38
38
  if (field === undefined)
@@ -40,7 +40,7 @@ export function removedOptionRefusal(options) {
40
40
  return {
41
41
  ok: false,
42
42
  code: "HUMANISH_STUDY_OPTION_UNSUPPORTED",
43
- message: `RunLabOptions.${field} was removed. ${REMOVED_OPTIONS[field]} See docs/contracts/schemas.md, "Library options".`,
43
+ message: `\`${field}\` was removed. ${REMOVED_OPTIONS[field]} See docs/contracts/schemas.md, "Library options".`,
44
44
  };
45
45
  }
46
46
  /** Why the route cannot honor an option it was given, or undefined when it can. */
@@ -633,6 +633,11 @@ The receipt describes the transformation; it does not attest that a participant'
633
633
  finding is true. Its own bytes are not included in its hash inventory. Feedback
634
634
  commands can subsequently generate new derivative-local artifacts.
635
635
 
636
+ The derivative names no sandbox. In every JSON and NDJSON file it copies, the
637
+ values at `sandboxId`, `subjectSandboxId` and each `providerResources[].id` read
638
+ `[redacted-sandbox-id]`; `sandbox-receipts.ndjson` is omitted. The source keeps
639
+ its raw ids, which cleanup and reclaim read.
640
+
636
641
  PNG files that actor traces reference as stream screenshots are re-encoded as
637
642
  blurred thumbnails. A PNG that nothing in `run.json` cites is omitted and
638
643
  inventoried. Export refuses a run whose feedback candidates, adapter artifacts or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.109.0",
3
+ "version": "0.109.1",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -91,6 +91,7 @@
91
91
  "prose:check": "node scripts/check-code-prose.mjs",
92
92
  "vocabulary:check": "tsx scripts/check-retired-words.ts",
93
93
  "site-css:check": "node scripts/check-site-css.mjs",
94
+ "site:tour": "tsx scripts/site-tour.ts",
94
95
  "site:hero:proof": "node scripts/site-hero-wrap-proof.mjs"
95
96
  },
96
97
  "dependencies": {