humanish 0.122.0 → 0.123.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 (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +33 -6
  4. package/dist/analysis/automatic.js +2 -1
  5. package/dist/analysis/evidence.js +41 -21
  6. package/dist/cli/commands/study-run.js +9 -3
  7. package/dist/feedback/derivative-text.d.ts +12 -0
  8. package/dist/feedback/derivative-text.js +130 -0
  9. package/dist/feedback/export-bundle.js +38 -131
  10. package/dist/feedback/export.js +9 -1
  11. package/dist/observer/run-routes.js +61 -18
  12. package/dist/run/bundle-shape.js +2 -2
  13. package/dist/run/bundle.d.ts +5 -2
  14. package/dist/run/bundle.js +4 -1
  15. package/dist/run/detail.d.ts +6 -0
  16. package/dist/run/detail.js +14 -1
  17. package/dist/run/locate.js +3 -1
  18. package/dist/run/projection.d.ts +7 -0
  19. package/dist/run/projection.js +10 -0
  20. package/dist/run/run.js +7 -1
  21. package/dist/run/stored-runs.js +10 -4
  22. package/dist/run/stream-shape.d.ts +11 -1
  23. package/dist/run/stream-shape.js +26 -2
  24. package/dist/run/stream-trace.d.ts +68 -0
  25. package/dist/run/stream-trace.js +260 -0
  26. package/dist/run/streams.d.ts +20 -0
  27. package/dist/study/bundle-estimate.d.ts +48 -0
  28. package/dist/study/bundle-estimate.js +159 -0
  29. package/dist/study/doctor.js +4 -0
  30. package/dist/study/plan-types.d.ts +3 -0
  31. package/dist/study/plan.js +22 -2
  32. package/dist/study/preflight.js +8 -0
  33. package/dist/tui-app.js +87 -87
  34. package/dist/verify/artifacts.d.ts +17 -10
  35. package/dist/verify/artifacts.js +22 -13
  36. package/dist/verify/scan-limit.d.ts +6 -0
  37. package/dist/verify/scan-limit.js +25 -0
  38. package/dist/verify/verify.d.ts +1 -1
  39. package/dist/verify/verify.js +68 -18
  40. package/docs/architecture/github-feedback-loop.md +1 -1
  41. package/docs/contracts/README.md +1 -1
  42. package/docs/contracts/run-bundle.md +87 -6
  43. package/docs/contracts/schemas.md +14 -7
  44. package/package.json +6 -3
@@ -2,19 +2,20 @@ import { type PreparedRunArtifactPaths } from "../run/paths.js";
2
2
  import type { RunBundle } from "../run/bundle.js";
3
3
  /** Public-safety and evidence-reference findings stop at this many per list. */
4
4
  export declare const MAX_REPORTED_FINDINGS = 50;
5
- /**
6
- * The most entries, files and directories, the public-safety scan visits in one run. Bundle export
7
- * refuses a run with more, so the scan never passes a run whose files it did not all read.
8
- */
9
- export declare const MAX_RUN_ENTRIES = 10000;
10
5
  /** What the public-safety scan did not read. A run with any of it cannot grade share_ready. */
11
6
  export interface UnscannedArtifacts {
12
7
  /** Run files it could not read as text, by run-relative path. */
13
8
  readonly files: string[];
14
9
  /** Folders it could not list, by run-relative path ("" is the run folder). */
15
10
  readonly unlistedFolders: string[];
16
- /** The folder it was listing when it reached MAX_RUN_ENTRIES. It read nothing listed after. */
17
- stoppedIn?: string;
11
+ /**
12
+ * Where the scan stopped: the folder it was listing when it passed its entry limit
13
+ * (runEntryLimit) and that limit. It read nothing listed after.
14
+ */
15
+ stopped?: {
16
+ readonly folder: string;
17
+ readonly limit: number;
18
+ };
18
19
  }
19
20
  /**
20
21
  * The UNSCANNED_ARTIFACT reason: the files the scan could not read, the folders it could not list
@@ -66,8 +67,14 @@ interface RegisteredStreamMedia {
66
67
  * registered stream media and that the scan cannot read as text (readPlainText), cannot read at
67
68
  * all, or that holds more than RUN_ARTIFACT_MAX_BYTES goes to `unscanned.files`, so the caller can
68
69
  * keep the run from grading share_ready. A directory it cannot list goes to
69
- * `unscanned.unlistedFolders`, and the directory it was listing when it passed MAX_RUN_ENTRIES
70
- * entries is `unscanned.stoppedIn`.
70
+ * `unscanned.unlistedFolders`. It visits at most runEntryLimit(`participants`) entries, the
71
+ * participants the run's run.json records, and where it stopped past them is `unscanned.stopped`.
72
+ */
73
+ export declare function scanRunPublicSafetyArtifacts(runPaths: PreparedRunArtifactPaths, derivedFindings: string[], media: RegisteredStreamMedia, unscanned: UnscannedArtifacts, participants: number): Promise<string[]>;
74
+ /**
75
+ * The public-safety scan of one file read as source evidence, an indexed trace, by its bytes and
76
+ * whatever its path: no exemption for a regenerated projection applies. `unscanned` when the bytes
77
+ * are not plain text or hold an encoding the scan cannot read.
71
78
  */
72
- export declare function scanRunPublicSafetyArtifacts(runPaths: PreparedRunArtifactPaths, derivedFindings: string[], media: RegisteredStreamMedia, unscanned: UnscannedArtifacts): Promise<string[]>;
79
+ export declare function scanSourceEvidence(bytes: Buffer): "clean" | "sensitive" | "unscanned";
73
80
  export {};
@@ -9,6 +9,7 @@ import { isLocalEvidenceArtifactPath, isRiskyPublicArtifactPath, validatePrepare
9
9
  import { openContainedRegularFile, RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
10
10
  import { holdsKeyedSandboxId } from "../run/sandbox-ids.js";
11
11
  import { SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
12
+ import { runEntryLimit } from "./scan-limit.js";
12
13
  import { readSafeRunArtifact, readSafeRunArtifactBytes, readSafeRunArtifactJson, } from "../run/locate.js";
13
14
  import { isRecord } from "../run/type-guards.js";
14
15
  import { TERMINAL_EVENTS_ARTIFACT } from "../run/terminal-contract.js";
@@ -16,11 +17,6 @@ import { plural } from "../run/text.js";
16
17
  import { isZeroEventTerminalTrace } from "./actor.js";
17
18
  /** Public-safety and evidence-reference findings stop at this many per list. */
18
19
  export const MAX_REPORTED_FINDINGS = 50;
19
- /**
20
- * The most entries, files and directories, the public-safety scan visits in one run. Bundle export
21
- * refuses a run with more, so the scan never passes a run whose files it did not all read.
22
- */
23
- export const MAX_RUN_ENTRIES = 10_000;
24
20
  // The UNSCANNED_ARTIFACT reason names this many paths of each kind and counts the rest.
25
21
  const MAX_LISTED_UNSCANNED = 10;
26
22
  /**
@@ -35,11 +31,11 @@ export function unscannedMessage(unscanned) {
35
31
  sentences.push(`The public-safety scan cannot read ${plural(files.length, "file")} as text that ${files.length === 1 ? "is not a stream screenshot under screenshots/ or a registered recording" : "are not stream screenshots under screenshots/ or registered recordings"}: ${listed(files)}.`);
36
32
  if (folders.length > 0)
37
33
  sentences.push(`The public-safety scan could not list ${plural(folders.length, "folder")}, so it read none of the files in ${folders.length === 1 ? "it" : "them"}: ${listed(folders)}.`);
38
- if (unscanned.stoppedIn !== undefined)
39
- sentences.push(`The public-safety scan stopped at its limit of ${MAX_RUN_ENTRIES} files and folders while listing ${folderName(unscanned.stoppedIn)}, so it read nothing listed after that point.`);
34
+ if (unscanned.stopped !== undefined)
35
+ sentences.push(`The public-safety scan stopped at its limit of ${unscanned.stopped.limit} files and folders while listing ${folderName(unscanned.stopped.folder)}, so it read nothing listed after that point.`);
40
36
  if (sentences.length === 0)
41
37
  return undefined;
42
- const onlyFiles = folders.length === 0 && unscanned.stoppedIn === undefined;
38
+ const onlyFiles = folders.length === 0 && unscanned.stopped === undefined;
43
39
  sentences.push(onlyFiles ? "Review them before sharing." : "Review the run folder before sharing.");
44
40
  return sentences.join(" ");
45
41
  }
@@ -323,14 +319,15 @@ export function rawScreenshotStreamIds(bundle, redactedShapeFrames) {
323
319
  * registered stream media and that the scan cannot read as text (readPlainText), cannot read at
324
320
  * all, or that holds more than RUN_ARTIFACT_MAX_BYTES goes to `unscanned.files`, so the caller can
325
321
  * keep the run from grading share_ready. A directory it cannot list goes to
326
- * `unscanned.unlistedFolders`, and the directory it was listing when it passed MAX_RUN_ENTRIES
327
- * entries is `unscanned.stoppedIn`.
322
+ * `unscanned.unlistedFolders`. It visits at most runEntryLimit(`participants`) entries, the
323
+ * participants the run's run.json records, and where it stopped past them is `unscanned.stopped`.
328
324
  */
329
- export async function scanRunPublicSafetyArtifacts(runPaths, derivedFindings, media, unscanned) {
325
+ export async function scanRunPublicSafetyArtifacts(runPaths, derivedFindings, media, unscanned, participants) {
330
326
  const findings = [];
331
327
  await validatePreparedRunArtifactPaths(runPaths);
332
328
  await scanRunPublicSafetyDirectory(runPaths, "", findings, derivedFindings, media, unscanned, {
333
329
  entries: 0,
330
+ limit: runEntryLimit(participants),
334
331
  });
335
332
  await validatePreparedRunArtifactPaths(runPaths);
336
333
  return findings;
@@ -351,9 +348,9 @@ async function scanRunPublicSafetyDirectory(runPaths, relativeDirectory, finding
351
348
  }
352
349
  for (const entryName of entries) {
353
350
  walk.entries += 1;
354
- if (walk.entries > MAX_RUN_ENTRIES) {
351
+ if (walk.entries > walk.limit) {
355
352
  // Every directory the walk returns through comes here once more; the first one stopped it.
356
- unscanned.stoppedIn ??= relativeDirectory;
353
+ unscanned.stopped ??= { folder: relativeDirectory, limit: walk.limit };
357
354
  return;
358
355
  }
359
356
  const relativePath = relativeDirectory ? `${relativeDirectory}/${entryName}` : entryName;
@@ -424,6 +421,18 @@ async function scanRunPublicSafetyDirectory(runPaths, relativeDirectory, finding
424
421
  }
425
422
  }
426
423
  }
424
+ /**
425
+ * The public-safety scan of one file read as source evidence, an indexed trace, by its bytes and
426
+ * whatever its path: no exemption for a regenerated projection applies. `unscanned` when the bytes
427
+ * are not plain text or hold an encoding the scan cannot read.
428
+ */
429
+ export function scanSourceEvidence(bytes) {
430
+ const decoded = readPlainText(bytes);
431
+ if (!decoded.ok)
432
+ return "unscanned";
433
+ const scan = scanEncodedTextCached(decoded.text);
434
+ return scan.sensitive ? "sensitive" : scan.opaque ? "unscanned" : "clean";
435
+ }
427
436
  function normalizeLocalEvidenceReference(value) {
428
437
  if (!value || value.includes("://") || path.isAbsolute(value)) {
429
438
  return null;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The most entries the scan visits in a run of `participants` participants. run.json states the
3
+ * count, so it is clamped to the most participants a run's analysis covers, which keeps a forged
4
+ * count from making the scan unbounded.
5
+ */
6
+ export declare function runEntryLimit(participants: number): number;
@@ -0,0 +1,25 @@
1
+ // How many files and folders the public-safety scan visits in one run, and bundle export counts
2
+ // before it copies one. It lives apart from the scan so the planner can check a study's estimated
3
+ // file count against it without loading the scanner.
4
+ import { EVIDENCE_LIMITS } from "../analysis/analysis-limits.js";
5
+ /** The entries any run may hold, whatever its participants. */
6
+ const MAX_RUN_ENTRIES = 10_000;
7
+ /**
8
+ * The entries each participant adds to the limit. Measured on 2026-10-12: a participant writes a
9
+ * screenshot a model turn, live participants that worked 5 minutes or more took a turn every 10
10
+ * s (median of 42 sessions), and its trace file, screenshot folder and first frame add 3 more. A
11
+ * participant working an 8-hour window then holds about 2,900 entries; 5,000 leaves room for a
12
+ * dwell window's frames or a faster pace.
13
+ */
14
+ const ENTRIES_PER_PARTICIPANT = 5_000;
15
+ /**
16
+ * The most entries the scan visits in a run of `participants` participants. run.json states the
17
+ * count, so it is clamped to the most participants a run's analysis covers, which keeps a forged
18
+ * count from making the scan unbounded.
19
+ */
20
+ export function runEntryLimit(participants) {
21
+ const counted = Number.isFinite(participants)
22
+ ? Math.min(Math.max(0, Math.floor(participants)), EVIDENCE_LIMITS.participants)
23
+ : 0;
24
+ return MAX_RUN_ENTRIES + ENTRIES_PER_PARTICIPANT * counted;
25
+ }
@@ -17,7 +17,7 @@ export interface VerifyResult {
17
17
  shareSafety: {
18
18
  status: "share_ready" | "local_only" | "blocked";
19
19
  reasons: Array<{
20
- code: "VERIFY_FAILED" | "PUBLIC_SAFETY_FINDINGS" | "ANALYSIS_UNVERIFIED" | "RAW_SCREENSHOTS" | "RAW_SANDBOX_ID" | "CONTINUOUS_MEDIA" | "REAL_COMMUNICATIONS" | "UNSCANNED_ARTIFACT";
20
+ code: "VERIFY_FAILED" | "PUBLIC_SAFETY_FINDINGS" | "ANALYSIS_UNVERIFIED" | "RAW_SCREENSHOTS" | "RAW_SANDBOX_ID" | "CONTINUOUS_MEDIA" | "REAL_COMMUNICATIONS" | "UNSCANNED_ARTIFACT" | "RUN_NOT_FINISHED";
21
21
  message: string;
22
22
  }>;
23
23
  };
@@ -5,15 +5,16 @@ import { analysisSharingProblems } from "../analysis/sharing.js";
5
5
  import { containsSensitive, REDACTED_SANDBOX_ID } from "../evidence/redaction.js";
6
6
  import { keyedSandboxIds, readRunSandboxIds } from "../run/sandbox-ids.js";
7
7
  import { validatePreparedRunArtifactPaths } from "../run/paths.js";
8
- import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA } from "../run/bundle.js";
8
+ import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "../run/bundle.js";
9
9
  import { ContainedReadRefusedError, refusalText } from "../run/contained-output.js";
10
10
  import { isCleanupResult, isRunBundle } from "../run/bundle-shape.js";
11
+ import { loadStreamTraces } from "../run/stream-trace.js";
11
12
  import { readRunJsonIfExists, readRunTextIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
12
13
  import { SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
13
14
  import { isRecord } from "../run/type-guards.js";
14
15
  import { runNotFoundMessage } from "../run/run-not-found.js";
15
16
  import { actorVerdictConsistencyFindings, noEngagementActorFindings, validateCodexAppServerEvidence, validateTerminalProductEvidence, } from "./actor.js";
16
- import { invalidRunEvidenceReferences, MAX_REPORTED_FINDINGS, missingLocalEvidenceArtifacts, rawScreenshotPostureWarnings, rawScreenshotStreamIds, redactedShapeFramePaths, scanRunPublicSafetyArtifacts, streamScreenshotPaths, unscannedMessage, } from "./artifacts.js";
17
+ import { invalidRunEvidenceReferences, MAX_REPORTED_FINDINGS, missingLocalEvidenceArtifacts, rawScreenshotPostureWarnings, rawScreenshotStreamIds, redactedShapeFramePaths, scanRunPublicSafetyArtifacts, scanSourceEvidence, streamScreenshotPaths, unscannedMessage, } from "./artifacts.js";
17
18
  import { flatContextWarnings } from "./context-growth.js";
18
19
  import { runNotFinished } from "./liveness.js";
19
20
  import { costAndReceiptFindings } from "./costs.js";
@@ -75,7 +76,10 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
75
76
  }
76
77
  const bundlePath = path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE);
77
78
  const bundleRead = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
78
- const bundle = runJsonValue(bundleRead);
79
+ // Every check below reads the streams with their traces loaded; a trace that does not match its
80
+ // index fails the evidence-artifacts check and its stream is checked without its items.
81
+ const traces = await loadTraces(runPaths, runJsonValue(bundleRead));
82
+ const { bundle, refusedTraces } = traces;
79
83
  const cleanupRead = await readRunJsonIfExists(runPaths, "cleanup.json");
80
84
  const review = {
81
85
  json: await readRunJsonIfExists(runPaths, "review.json"),
@@ -103,7 +107,8 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
103
107
  // A run from before 0.110 with no receipts still names its ids in run.json.
104
108
  sandboxIds: [...new Set([...journaledSandboxIds, ...keyedSandboxIds(bundle)])],
105
109
  sandboxIdFiles,
106
- }, unscannedArtifacts);
110
+ }, unscannedArtifacts, isRunBundle(bundle) ? bundle.simulations.length : 0);
111
+ addTraceScan(traces, publicSafetyFindings, unscannedArtifacts);
107
112
  // JSON escapes can hide a sensitive value from the byte scan while the
108
113
  // decoded recording exposes it to Observer, feedback, or analysis input.
109
114
  if (publicSafetyFindings.length < MAX_REPORTED_FINDINGS &&
@@ -125,17 +130,14 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
125
130
  ? await redactedShapeFramePaths(runPaths, bundle)
126
131
  : new Set();
127
132
  const invalidEvidenceReferences = isRunBundle(bundle) ? invalidRunEvidenceReferences(bundle) : [];
128
- checks.push({
129
- name: "local evidence artifacts exist",
130
- ok: missingEvidenceArtifacts.length === 0 && invalidEvidenceReferences.length === 0,
131
- message: missingEvidenceArtifacts.length === 0 && invalidEvidenceReferences.length === 0
132
- ? isRunBundle(bundle)
133
- ? "every referenced screenshot, trace, log and filesystem artifact is present"
134
- : SHAPE_UNCHECKED
135
- : invalidEvidenceReferences.length > 0
136
- ? `invalid evidence artifact references: ${invalidEvidenceReferences.join(", ")}`
137
- : `missing local evidence artifacts: ${missingEvidenceArtifacts.join(", ")}`,
138
- });
133
+ checks.push(evidenceArtifactsCheck(isRunBundle(bundle), [
134
+ ...refusedTraces,
135
+ ...(invalidEvidenceReferences.length > 0
136
+ ? [`invalid evidence artifact references: ${invalidEvidenceReferences.join(", ")}`]
137
+ : missingEvidenceArtifacts.length > 0
138
+ ? [`missing local evidence artifacts: ${missingEvidenceArtifacts.join(", ")}`]
139
+ : []),
140
+ ]));
139
141
  checks.push(...(await evidenceChecks(runPaths, bundle, cleanupRead)));
140
142
  const recordingOk = checks.every((check) => check.ok);
141
143
  if (derivedPublicSafetyFindings.length > 0)
@@ -208,6 +210,46 @@ export async function verifyRunPrepared(cwdInput, runInput, runPaths) {
208
210
  }
209
211
  return verifyResolvedRun(cwd, runInput, runPaths);
210
212
  }
213
+ /** The evidence-artifacts check: refused traces, then invalid references or missing files. */
214
+ function evidenceArtifactsCheck(shaped, problems) {
215
+ return {
216
+ name: "local evidence artifacts exist",
217
+ ok: problems.length === 0,
218
+ message: problems.length > 0
219
+ ? problems.join(" ")
220
+ : shaped
221
+ ? "every referenced screenshot, trace, log and filesystem artifact is present"
222
+ : SHAPE_UNCHECKED,
223
+ };
224
+ }
225
+ /**
226
+ * run.json with each stream's trace loaded, one file at a time, and the refusal of each trace that
227
+ * does not match its index. A run.json that fails the shape guard is returned as it is.
228
+ */
229
+ async function loadTraces(runPaths, stored) {
230
+ const scanned = { sensitive: [], opaque: [] };
231
+ if (!isRunBundle(stored))
232
+ return { bundle: stored, refusedTraces: [], ...scanned };
233
+ // Each trace is source evidence: scanned by the bytes it was joined from, apart from the
234
+ // directory walk and any path exemption the walk makes.
235
+ const { bundle, refused } = await loadStreamTraces(runPaths, stored, {
236
+ onTrace: (file, bytes) => {
237
+ const scan = scanSourceEvidence(bytes);
238
+ if (scan !== "clean")
239
+ scanned[scan === "sensitive" ? "sensitive" : "opaque"].push(file);
240
+ },
241
+ });
242
+ return { bundle, refusedTraces: refused.map((error) => error.message), ...scanned };
243
+ }
244
+ /** The trace scan's results, beside the walk's: each file named once. */
245
+ function addTraceScan(traces, findings, unscanned) {
246
+ for (const file of traces.sensitive)
247
+ if (!findings.includes(`sensitive text ${file}`))
248
+ findings.push(`sensitive text ${file}`);
249
+ for (const file of traces.opaque)
250
+ if (!unscanned.files.includes(file))
251
+ unscanned.files.push(file);
252
+ }
211
253
  /** The top-level run.json sections the shape guard requires. */
212
254
  const REQUIRED_SECTIONS = [
213
255
  "source",
@@ -230,7 +272,7 @@ function bundlePresenceChecks(bundleRead, review) {
230
272
  const bundle = runJsonValue(bundleRead);
231
273
  const record = isRecord(bundle) ? bundle : undefined;
232
274
  const schema = record?.schema;
233
- const schemaOk = schema === RUN_BUNDLE_SCHEMA;
275
+ const schemaOk = schema === RUN_BUNDLE_SCHEMA || schema === RUN_BUNDLE_V1_SCHEMA;
234
276
  const redactionRecord = record?.redaction;
235
277
  const redaction = isRecord(redactionRecord) ? redactionRecord.status : undefined;
236
278
  const missingSections = REQUIRED_SECTIONS.filter((key) => record?.[key] === undefined);
@@ -260,10 +302,10 @@ function bundlePresenceChecks(bundleRead, review) {
260
302
  name: "run schema",
261
303
  ok: schemaOk,
262
304
  message: schemaOk
263
- ? `run.json declares ${RUN_BUNDLE_SCHEMA}`
305
+ ? `run.json declares ${String(schema)}`
264
306
  : record === undefined
265
307
  ? "no readable run.json, so no schema"
266
- : `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA}`,
308
+ : `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA} and ${RUN_BUNDLE_V1_SCHEMA}`,
267
309
  },
268
310
  {
269
311
  name: "run bundle shape",
@@ -397,6 +439,14 @@ function buildShareSafety(args) {
397
439
  const unscanned = unscannedMessage(args.unscannedArtifacts);
398
440
  if (unscanned !== undefined)
399
441
  reasons.push({ code: "UNSCANNED_ARTIFACT", message: unscanned });
442
+ // A v2 run.json binds each finished participant's items by its trace index. Items written while
443
+ // a participant was still working (liveActor) have no index, so such a run stays local.
444
+ const live = args.bundle.streams.filter((stream) => stream.liveActor !== undefined);
445
+ if (args.bundle.schema === RUN_BUNDLE_SCHEMA && live.length > 0)
446
+ reasons.push({
447
+ code: "RUN_NOT_FINISHED",
448
+ message: `The run did not finish: run.json holds in-progress items for ${live.map((stream) => stream.id).join(", ")}, which no trace file binds. Review it locally; rerun the study for evidence you can share.`,
449
+ });
400
450
  // Runs from 0.110 keep raw sandbox ids only in sandbox-receipts.ndjson. An earlier run records
401
451
  // them in run.json, and any file that names one is not share-ready as it is.
402
452
  const rawResources = (args.bundle.providerResources ?? []).some((resource) => resource.id !== REDACTED_SANDBOX_ID);
@@ -129,7 +129,7 @@ humanish_swarm:
129
129
  - .env*
130
130
  - .github/workflows/**
131
131
  - infra/**
132
- artifact_schema_version: humanish.run-bundle.v1
132
+ artifact_schema_version: humanish.run-bundle.v2
133
133
  credential_manifest: []
134
134
  network_policy: no_network
135
135
  spend_policy: no_spend
@@ -8,7 +8,7 @@ disagree.
8
8
  (`RunStudyOptions`) and the exports removed in 0.107.0.
9
9
  - [core.md](core.md): the records a run writes under `.humanish/runs/<id>/` and how each route
10
10
  starts a run.
11
- - [run-bundle.md](run-bundle.md): `humanish.run-bundle.v1`, the evidence contract that `verify`
11
+ - [run-bundle.md](run-bundle.md): `humanish.run-bundle.v2`, the evidence contract that `verify`
12
12
  checks.
13
13
  - [policy.md](policy.md): credential, network, spend, redaction, sharing and assisted-run
14
14
  boundaries.
@@ -1,6 +1,6 @@
1
1
  # Run Bundle Contract
2
2
 
3
- `humanish.run-bundle.v1` is the evidence contract. The TypeScript shape in `src/run/bundle.ts`
3
+ `humanish.run-bundle.v2` is the evidence contract. The TypeScript shape in `src/run/bundle.ts`
4
4
  (with `streams[]` in `src/run/streams.ts` and `sharedWorld` as `SharedWorldEvidence` in
5
5
  `src/run/shared-world-evidence.ts`) and the fail-closed checks in `src/verify/verify.ts` are the
6
6
  source of truth. This page explains the stable public fields and the extension rules; it does not
@@ -13,6 +13,77 @@ and `terminal-ledgers.json`. `true` means participant and product setup commands
13
13
  in the JSON message of the `terminal-lab.runtime.declared` event. Older bundles omit it.
14
14
  The setting does not attest that the studied CLI honors the variable.
15
15
 
16
+ ## Participant traces
17
+
18
+ From v2, run.json holds each participant's summary and the trace file holds its items. The run
19
+ writer (`src/run/run.ts`, through `splitStreamTraces` in `src/run/stream-trace.ts`) writes, for
20
+ each stream with an `actor`:
21
+
22
+ - the trace file: the actor trace (`humanish.actor-trace.v1`) with every item, plus `artifacts`,
23
+ the stream's screenshot entries. Its path is the stream's actor trace artifact (`actor.json` for
24
+ one participant, `actors/<stream>.json` for more, `actor-<surface>.json` on the scripted route),
25
+ or `actors/<n>.json`, `n` its position in `streams[]` (from 1), for a stream with none or whose
26
+ trace artifact an earlier stream claimed. Names of that form are never reused as a stream's own
27
+ artifact, so two streams never get one file;
28
+ - in run.json, `streams[].actor` without `items`, `streams[].artifacts` without the screenshot
29
+ entries, and the index:
30
+
31
+ ```yaml
32
+ trace:
33
+ path: actors/stream-001.json
34
+ bytes: 5480211 # the file's size
35
+ sha256: "<hex digest of the file>"
36
+ items: 23104 # entries in the file's items
37
+ screenshots: 2881 # entries in the file's artifacts
38
+ ```
39
+
40
+ run.json keeps every field readers list runs by: each stream's status, judged status,
41
+ assignment and geometry, and the actor's status, completion reason, counts, persona, debrief,
42
+ impressions, token usage and estimated cost. Each trace is written once.
43
+
44
+ `trace.path` must be one of those trace file names (`actor.json`, `actors/<name>.json`,
45
+ `actor-<name>.json`, with `<name>` a plain file name); `isStreamTracePath` in
46
+ `src/run/stream-shape.ts` holds the rule, and the writer, the shape check and the loader all use
47
+ it. Any other path, such as `observer/index.html`, fails the shape check.
48
+
49
+ Readers load a stream through `loadStreamTrace(runPaths, stream)`. A v1 stream returns as it is.
50
+ An indexed stream's file is read under the 32 MiB per-file limit (analysis uses its 16 MiB
51
+ source limit for each trace) and refused unless its size, sha256, item count and screenshot count
52
+ match the index. A trace file belongs to one participant: every stream whose index names a file
53
+ another stream's index names, compared without case as a case-insensitive file system compares
54
+ names, is refused before any file is read. The writer claims names the same way. In a v2 run.json every `actor`
55
+ must have an index: an actor with no index, with
56
+ or without inline `items`, is refused, and so is one with both an index and inline `items`.
57
+ verify reports a refused trace in the `local evidence artifacts exist` check, so the run is
58
+ `blocked`. verify also scans the bytes of each trace it loaded with the public-safety text scan,
59
+ apart from its walk of the run folder, so no path exemption the walk makes applies to a trace.
60
+ run.json's digest, which an analysis record binds, covers each index and so each trace.
61
+
62
+ Mid-run flushes: the writer indexes every finished participant on every write, flushes included,
63
+ so a v2 run.json never holds `actor.items`. A participant still working has its items so far in
64
+ `streams[].liveActor.items` (`humanish.live-actor.v1`), which no index binds. A v2 run.json may
65
+ carry `liveActor` only while its run has not finished: one whose `outcome.state` is `finished`
66
+ and still holds `liveActor` is refused. An unfinished v2 run.json with `liveActor` (a flush, or
67
+ the last write of an interrupted run) verifies as `local_only` with the share-safety reason
68
+ `RUN_NOT_FINISHED`, never `share_ready`. v1 bundles keep their earlier behavior.
69
+
70
+ Readers that show a run (the Observer server, run detail and the TUI, the review command's
71
+ provenance) load traces through the same loader and rules. A participant whose trace the loader
72
+ refused shows none of the items run.json or a file supplied for it: its stream carries
73
+ `traceUnavailable` with the reason, its Observer timeline a `trace.unavailable` warning, and its
74
+ run-detail entry `evidenceUnavailable`. The Observer server reads the saved observer-data.json
75
+ only while a run has no run.json at all; a run.json that is there and cannot be read, parsed or
76
+ used as a bundle answers 404 with the reason, never the saved projection. In safe serving
77
+ (`observe --all --safe`) every read is checked against the files admitted with the run's
78
+ share_ready verification, so a run.json changed after admission is refused and the admitted
79
+ projection is served.
80
+ observer-data.json is built from the loaded bundle and has the same shape as before.
81
+
82
+ Compatibility: humanish 0.122.0 and earlier read only v1 and refuse a v2 run.json by its schema
83
+ (`run schema` fails in verify, analysis refuses the source). This release reads v1 bundles as
84
+ they are, with no migration. An exported copy (`humanish export --format bundle`) is written as
85
+ v2 whatever the source version.
86
+
16
87
  ## Purpose
17
88
 
18
89
  A run bundle is the durable evidence packet for one harness run. It should be
@@ -22,7 +93,7 @@ feedback drafts and future public issues.
22
93
  ## Minimum Bundle Shape
23
94
 
24
95
  ```yaml
25
- schema: humanish.run-bundle.v1
96
+ schema: humanish.run-bundle.v2 # v1 bundles are still read
26
97
  runId: "<core run id>"
27
98
  mode: "dry-run|live"
28
99
  simCount: 1
@@ -522,9 +593,13 @@ For run id `example-2026-06-02t10-00-00-000z-proof`, the core layout is:
522
593
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/review.md
523
594
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/observer/observer-data.json
524
595
  .humanish/runs/example-2026-06-02t10-00-00-000z-proof/events.ndjson
596
+ .humanish/runs/example-2026-06-02t10-00-00-000z-proof/actors/stream-001.json
525
597
  .humanish/runs/latest.json
526
598
  ```
527
599
 
600
+ `actors/<stream>.json` (or `actor.json` for one participant) is a participant's trace file; see
601
+ [Participant traces](#participant-traces).
602
+
528
603
  Absolute paths, traversal segments, remotes, hosted logs, and private artifact
529
604
  URLs are not part of the core layout.
530
605
 
@@ -691,7 +766,9 @@ bundle is safe to promote into a public issue. Public promotion should branch on
691
766
  `ok: true` also does not mean the run finished. A run killed mid-way leaves an
692
767
  in-progress bundle that can pass every check. When the run is not finished,
693
768
  `warnings[]` carries one entry starting with the stable code `RUN_NOT_FINISHED`.
694
- `ok` and `shareSafety` do not change. Verify decides "not finished" by the run
769
+ `ok` does not change. `shareSafety` changes only for a v2 bundle that holds a participant's
770
+ in-progress items (`liveActor`): it is `local_only` with the reason `RUN_NOT_FINISHED`, since no
771
+ trace index binds those items. Verify decides "not finished" by the run
695
772
  index's rule, which the TUI and `humanish stats` use: the run's `status.json` when
696
773
  it is well formed and names the run, else a `simulations[]` entry still `running` in the
697
774
  bundle. A `running` record updated within the stale window reads as still
@@ -715,7 +792,8 @@ receipt records a sandbox not gone or misses a journaled one, and `unknown` othe
715
792
  including a receipt from humanish 0.110.0 or earlier, which did not search by tag.
716
793
 
717
794
  The local-evidence check includes screenshots declared only by
718
- `streams[].actor.items[].screenshotRef` or `streams[].liveActor.items[].screenshotRef`,
795
+ `streams[].actor.items[].screenshotRef` (in the trace file for a v2 bundle) or
796
+ `streams[].liveActor.items[].screenshotRef`,
719
797
  as well as every feedback candidate's evidence. Actor frame paths are relative
720
798
  to the run root; missing, malformed or nonlocal references fail verification.
721
799
  Screenshots use the existing bounded PNG decoder. Nonimage candidate evidence
@@ -749,8 +827,9 @@ writes every frame, and the recordings `streams[].recording` registers. Every
749
827
  other file the scan cannot read as text, cannot read at all, or that is larger
750
828
  than 32 MiB (verify reads at most that much of one file) contributes
751
829
  `UNSCANNED_ARTIFACT`. So does a folder the scan cannot list, and the rest of a
752
- run past its first 10,000 files and folders, the most `export --format bundle`
753
- takes. The reason's message lists the paths, and it keeps otherwise valid
830
+ run past its first 10,000 files and folders plus 5,000 for each participant
831
+ `run.json` records, counting at most 128. `export --format bundle` takes at most
832
+ 10,000. The reason's message lists the paths, and it keeps otherwise valid
754
833
  evidence `local_only`. This includes images that only feedback candidates,
755
834
  adapter artifacts or stream artifact entries cite, and a PNG an actor trace
756
835
  references outside `screenshots/`. An adapter artifact larger than 32 MiB grades
@@ -848,6 +927,8 @@ does not inherit resource cleanup authority. Unsupported images, binary formats,
848
927
  inline image payloads, invalid evidence and references to omitted files are refused.
849
928
  The original and derivative are independently verified. Text still requires human
850
929
  review before sharing; screenshot blur does not certify natural-language privacy.
930
+ Export counts the run's files and folders against the public-safety scan's limit
931
+ (10,000 plus 5,000 for each participant `run.json` records) and refuses a run past it.
851
932
 
852
933
  ## Git Provenance
853
934
 
@@ -26,7 +26,7 @@ workflow without leaking private upstream truth into core.
26
26
 
27
27
  | Contract | Schema | Public-safe fixture |
28
28
  | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
29
- | Run bundle | `humanish.run-bundle.v1` | `synthetic-run-bundle` |
29
+ | Run bundle | `humanish.run-bundle.v2` (each participant's trace in its own file, indexed by `streams[].trace`; v1 bundles, with traces inline, are still read) | `synthetic-run-bundle` |
30
30
  | Adapter | `humanish.adapter.v1` | `synthetic-cli-adapter` |
31
31
  | Study file | `humanish.study.v3` | `first-run` |
32
32
  | Persona | `humanish.persona.v1` | `synthetic-maintainer` |
@@ -471,7 +471,7 @@ A study's result has one schema on every route, `humanish.study-result.v1`
471
471
  (`src/run/study-result.ts`), with `route` and `studyId`. 0.109.0 removed `labId`, which 0.108.0
472
472
  wrote with the same value. Route-specific fields stay on their route's result. `humanish run`
473
473
  with no study, `observe`, and a refusal made before a route is picked keep
474
- `humanish.run-result.v1`. The evidence record stays `humanish.run-bundle.v1` in every case.
474
+ `humanish.run-result.v1`. The evidence record stays `humanish.run-bundle.v2` in every case.
475
475
  The computer-use result carries `plan` (the pre-flight participant table: concurrency, waves,
476
476
  per-participant session budget, worst-case sandbox-minutes), `lanes[]` (always present,
477
477
  length 1 at N=1; per-participant status/session/sandbox/subject), and `laneSummary`
@@ -578,7 +578,7 @@ Core-owned fields:
578
578
  }`. Emitted by the computer-use and shared-world routes and the clone
579
579
  scripted-browser route; absent on pre-existing and other bundles. `repo`/`commit` are clone-route fields; `archiveSha256`
580
580
  (64-hex sha256, the local-tree provenance pin) and `dirty` (host git
581
- porcelain status at pack time) are local-tree-route fields, additive under
581
+ porcelain status at pack time) are local-tree-route fields, additive since
582
582
  `humanish.run-bundle.v1`: a dirty working tree cannot be commit-pinned, so
583
583
  the archive content digest stands in for it. `product` is the desktop-cli field: the
584
584
  `subject.product.name` the study declares, with state `undeclared`. A computer-use
@@ -616,7 +616,7 @@ Adapter-owned fields:
616
616
  Abbreviated synthetic fixture (omits `cwd`, `simulations`, `streams` and `events`):
617
617
 
618
618
  ```yaml
619
- schema: humanish.run-bundle.v1
619
+ schema: humanish.run-bundle.v2
620
620
  runId: synthetic-run-bundle-2026-06-02t10-00-00-000z-proof
621
621
  mode: dry-run
622
622
  simCount: 1
@@ -686,8 +686,8 @@ sequential shared-world route, which 0.106.0 removed. Only bundles written befor
686
686
  (`tests/run/legacy-sequential-shared-world.test.ts`). The sequential rules below apply
687
687
  to those bundles.
688
688
 
689
- A shared-world bundle adds two additive, optional fields to `humanish.run-bundle.v1`
690
- (absent on every other bundle, so they stay byte-stable):
689
+ A shared-world bundle adds two additive, optional fields, added under `humanish.run-bundle.v1`
690
+ (absent on every other bundle):
691
691
 
692
692
  - `attributionClass: isolated | shared-world`: a separate attribution axis
693
693
  ("how well did the run attribute interaction?"), distinct from the persona-sampling
@@ -967,6 +967,13 @@ output all map onto one `ActorTrace`. Registered actors live in
967
967
  `pi-agent-core` or `claude-agent-sdk` fails to parse. There is no
968
968
  `humanish.actor.v1`; that name never shipped.
969
969
 
970
+ Where the trace is stored depends on the run bundle version. In `humanish.run-bundle.v2`,
971
+ `streams[].actor` in run.json is the trace without `items`, and the full trace, with the stream's
972
+ screenshot entries as `artifacts`, is the file `streams[].trace.path` names (`actor.json`,
973
+ `actors/<stream>.json` or `actor-<surface>.json`). A v1 run.json holds the full trace inline.
974
+ `loadStreamTrace` in `src/run/stream-trace.ts` reads either; see
975
+ [run-bundle.md](run-bundle.md#participant-traces).
976
+
970
977
  Core-owned fields:
971
978
 
972
979
  - `schema`
@@ -1822,7 +1829,7 @@ including existence for referenced adapter artifacts.
1822
1829
 
1823
1830
  Reserved: `humanish.evidence-stream.v1` has never shipped as a standalone
1824
1831
  schema, and streams are not standalone artifacts. They are the `streams` array
1825
- inside `humanish.run-bundle.v1`, normalizing UI, browser, terminal, TUI,
1832
+ inside the run bundle (`humanish.run-bundle.v2`), normalizing UI, browser, terminal, TUI,
1826
1833
  code-agent UI, artifact, and summary streams, each with transport, terminal
1827
1834
  tail, completion, meaningful-use verdicts, and artifact pointers. See
1828
1835
  [`run-bundle.md`](run-bundle.md#completion-and-meaningful-use-verdicts) for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.122.0",
3
+ "version": "0.123.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -56,7 +56,9 @@
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsc -p tsconfig.build.json && pnpm --filter humanish-observer build && pnpm --filter humanish-tui build && node scripts/finish-build.mjs",
59
- "check": "pnpm format:check && pnpm lint && pnpm knip && pnpm prose:check && pnpm vocabulary:check && pnpm docs:check && pnpm site-css:check && pnpm typecheck && pnpm test && pnpm tui:test && pnpm build && pnpm guest-media:startup:proof && pnpm cli:startup:test && pnpm cli:preflight:test && pnpm cli:dotenv:test && pnpm cli:peer:test && pnpm cli:skill:test && pnpm tui:smoke",
59
+ "check": "pnpm check:pre && pnpm test && pnpm check:post",
60
+ "check:pre": "pnpm format:check && pnpm lint && pnpm knip && pnpm prose:check && pnpm vocabulary:check && pnpm docs:check && pnpm site-css:check && pnpm typecheck",
61
+ "check:post": "pnpm tui:test && pnpm build && pnpm guest-media:startup:proof && pnpm cli:startup:test && pnpm cli:preflight:test && pnpm cli:dotenv:test && pnpm cli:peer:test && pnpm cli:skill:test && pnpm tui:smoke",
60
62
  "cli:startup:test": "node scripts/terminal-startup-exit-proof.mjs",
61
63
  "cli:preflight:test": "node scripts/task-route-preflight-proof.mjs",
62
64
  "cli:dotenv:test": "node scripts/dotenv-proof.mjs",
@@ -67,7 +69,8 @@
67
69
  "prepack": "pnpm build",
68
70
  "public-surface:scan": "node scripts/public-surface-scan.mjs",
69
71
  "api:proof": "node scripts/public-api-proof.mjs",
70
- "release:check": "pnpm check && pnpm api:proof && pnpm public-surface:scan && pnpm skill:check && npm pack --dry-run",
72
+ "release:check": "pnpm check:pre && pnpm test && pnpm release:check:rest",
73
+ "release:check:rest": "pnpm check:post && pnpm api:proof && pnpm public-surface:scan && pnpm skill:check && npm pack --dry-run",
71
74
  "skill:check": "DISABLE_TELEMETRY=1 pnpm exec skills add . --list",
72
75
  "test": "vitest run",
73
76
  "typecheck": "tsc -p tsconfig.json --noEmit && pnpm --filter humanish-observer typecheck && pnpm --filter humanish-tui typecheck",