humanish 0.109.1 → 0.110.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.
Files changed (164) hide show
  1. package/ARCHITECTURE.md +14 -11
  2. package/CHANGELOG.md +24 -0
  3. package/CONTEXT.md +8 -6
  4. package/README.md +97 -48
  5. package/dist/actors/computer-use/loop/ending.js +4 -4
  6. package/dist/actors/local-agent/claude-participant.d.ts +74 -0
  7. package/dist/actors/local-agent/claude-participant.js +243 -0
  8. package/dist/actors/local-agent/claude-session.d.ts +16 -3
  9. package/dist/actors/local-agent/claude-session.js +64 -23
  10. package/dist/actors/local-agent/cli.d.ts +11 -0
  11. package/dist/actors/local-agent/cli.js +103 -24
  12. package/dist/actors/local-agent/readiness.d.ts +2 -2
  13. package/dist/actors/local-agent/readiness.js +18 -11
  14. package/dist/cli/commands/analyze.js +21 -5
  15. package/dist/cli/commands/comms.js +25 -7
  16. package/dist/cli/commands/observe.js +4 -2
  17. package/dist/cli/commands/run-command.js +4 -3
  18. package/dist/cli/commands/run-signals.d.ts +2 -0
  19. package/dist/cli/commands/run-signals.js +96 -44
  20. package/dist/cli/commands/runs.d.ts +5 -0
  21. package/dist/cli/commands/runs.js +89 -67
  22. package/dist/cli/commands/setup.js +19 -5
  23. package/dist/cli/commands/study-format.js +29 -2
  24. package/dist/cli/commands/study-route-computer-use.js +2 -0
  25. package/dist/cli/commands/study-route-scripted.js +2 -0
  26. package/dist/cli/commands/study-route-shared-world.js +2 -0
  27. package/dist/cli/commands/study-route-terminal.js +2 -0
  28. package/dist/cli/commands/study.js +4 -3
  29. package/dist/cli/commands/tui.js +4 -3
  30. package/dist/cli/commands/watch.js +2 -2
  31. package/dist/cli/doctor.d.ts +7 -2
  32. package/dist/cli/doctor.js +47 -8
  33. package/dist/cli/findings.d.ts +103 -0
  34. package/dist/cli/findings.js +316 -0
  35. package/dist/cli/first-run-path.d.ts +5 -4
  36. package/dist/cli/first-run-path.js +14 -7
  37. package/dist/cli/io.d.ts +17 -3
  38. package/dist/cli/io.js +22 -4
  39. package/dist/cli/product-sentence.d.ts +3 -3
  40. package/dist/cli/product-sentence.js +3 -3
  41. package/dist/comms/receiving-runtime.js +1 -1
  42. package/dist/comms/setup.js +1 -1
  43. package/dist/evidence/redaction.d.ts +7 -3
  44. package/dist/evidence/redaction.js +68 -24
  45. package/dist/feedback/export-bundle.js +6 -1
  46. package/dist/feedback/export.js +3 -0
  47. package/dist/keys/env-file.d.ts +4 -0
  48. package/dist/keys/env-file.js +17 -0
  49. package/dist/keys/key-resolution.d.ts +7 -0
  50. package/dist/keys/key-resolution.js +10 -8
  51. package/dist/observer/data.d.ts +14 -1
  52. package/dist/observer/data.js +19 -9
  53. package/dist/observer/library.d.ts +4 -0
  54. package/dist/observer/library.js +18 -15
  55. package/dist/observer/render.js +8 -2
  56. package/dist/observer/run-routes.d.ts +2 -0
  57. package/dist/observer/run-routes.js +43 -34
  58. package/dist/observer-app.html +3 -3
  59. package/dist/routes/computer-use/bundle.js +3 -1
  60. package/dist/routes/computer-use/fanout-records.js +1 -1
  61. package/dist/routes/computer-use/live-phase.d.ts +1 -0
  62. package/dist/routes/computer-use/participant-execution.js +3 -1
  63. package/dist/routes/computer-use/participant-model.js +5 -2
  64. package/dist/routes/computer-use/plan.js +4 -4
  65. package/dist/routes/computer-use/preflight.js +2 -2
  66. package/dist/routes/computer-use/result.js +17 -28
  67. package/dist/routes/computer-use/single-bundle.js +7 -1
  68. package/dist/routes/computer-use/types.d.ts +6 -0
  69. package/dist/routes/scripted/bundle.js +4 -2
  70. package/dist/routes/scripted/plan.js +2 -2
  71. package/dist/routes/scripted/result.d.ts +3 -5
  72. package/dist/routes/scripted/result.js +16 -22
  73. package/dist/routes/scripted/scenario.js +4 -4
  74. package/dist/routes/scripted/types.d.ts +1 -0
  75. package/dist/routes/shared-world/bundle-records.js +6 -1
  76. package/dist/routes/shared-world/bundle.js +2 -0
  77. package/dist/routes/shared-world/plan.js +1 -1
  78. package/dist/routes/shared-world/result.js +22 -22
  79. package/dist/routes/shared-world/route.js +1 -1
  80. package/dist/routes/shared-world/types.d.ts +2 -0
  81. package/dist/routes/terminal/bundle.js +4 -2
  82. package/dist/routes/terminal/credentials.js +1 -1
  83. package/dist/routes/terminal/dry-run.js +8 -12
  84. package/dist/routes/terminal/ledger.d.ts +3 -3
  85. package/dist/routes/terminal/ledger.js +6 -6
  86. package/dist/routes/terminal/live-finish.js +23 -17
  87. package/dist/routes/terminal/plan.js +4 -4
  88. package/dist/routes/terminal/result.d.ts +6 -7
  89. package/dist/routes/terminal/result.js +6 -22
  90. package/dist/routes/terminal/session.js +1 -1
  91. package/dist/routes/terminal/types.d.ts +3 -0
  92. package/dist/run/bundle-shape.d.ts +3 -1
  93. package/dist/run/bundle-shape.js +26 -1
  94. package/dist/run/bundle.d.ts +30 -0
  95. package/dist/run/display.d.ts +59 -0
  96. package/dist/run/display.js +106 -0
  97. package/dist/run/dry-run.js +11 -7
  98. package/dist/run/judge.d.ts +3 -2
  99. package/dist/run/judge.js +10 -0
  100. package/dist/run/pricing.js +1 -1
  101. package/dist/run/projection.d.ts +0 -2
  102. package/dist/run/projection.js +0 -11
  103. package/dist/run/reclaim-outcomes.d.ts +89 -0
  104. package/dist/run/reclaim-outcomes.js +213 -0
  105. package/dist/run/reclaim.d.ts +18 -14
  106. package/dist/run/reclaim.js +179 -105
  107. package/dist/run/results.d.ts +11 -18
  108. package/dist/run/results.js +2 -2
  109. package/dist/run/run-index.d.ts +11 -1
  110. package/dist/run/run-index.js +11 -0
  111. package/dist/run/run.d.ts +34 -18
  112. package/dist/run/run.js +177 -72
  113. package/dist/run/sandbox-creates.d.ts +59 -0
  114. package/dist/run/sandbox-creates.js +138 -0
  115. package/dist/run/sandbox-ids.d.ts +53 -0
  116. package/dist/run/sandbox-ids.js +198 -0
  117. package/dist/run/sandbox-receipts.d.ts +16 -0
  118. package/dist/run/sandbox-receipts.js +70 -0
  119. package/dist/run/stats.d.ts +5 -0
  120. package/dist/run/stats.js +8 -4
  121. package/dist/run/status.d.ts +6 -9
  122. package/dist/run/status.js +7 -20
  123. package/dist/run/stored-runs.d.ts +5 -14
  124. package/dist/run/stored-runs.js +31 -137
  125. package/dist/run/streams.d.ts +3 -2
  126. package/dist/run-study.js +6 -1
  127. package/dist/study/doctor.d.ts +1 -1
  128. package/dist/study/doctor.js +7 -4
  129. package/dist/study/init-templates.js +2 -2
  130. package/dist/study/init.js +14 -3
  131. package/dist/study/keys.d.ts +12 -0
  132. package/dist/study/keys.js +32 -0
  133. package/dist/study/persona.d.ts +4 -0
  134. package/dist/study/persona.js +4 -2
  135. package/dist/study/types.d.ts +1 -1
  136. package/dist/substrates/e2b/peer-install.d.ts +51 -0
  137. package/dist/substrates/e2b/peer-install.js +161 -0
  138. package/dist/substrates/e2b/sandbox.d.ts +34 -6
  139. package/dist/substrates/e2b/sandbox.js +128 -35
  140. package/dist/substrates/e2b/sdk.d.ts +33 -0
  141. package/dist/substrates/e2b/sdk.js +21 -21
  142. package/dist/substrates/shell.d.ts +2 -0
  143. package/dist/substrates/shell.js +4 -0
  144. package/dist/tui-app.js +133 -133
  145. package/dist/verify/artifacts.d.ts +4 -0
  146. package/dist/verify/artifacts.js +8 -0
  147. package/dist/verify/liveness.d.ts +19 -3
  148. package/dist/verify/liveness.js +53 -28
  149. package/dist/verify/verify.d.ts +4 -1
  150. package/dist/verify/verify.js +27 -3
  151. package/docs/architecture/actor-contract.md +2 -1
  152. package/docs/architecture/observer.md +22 -7
  153. package/docs/architecture/restricted-codex-analysis.md +1 -1
  154. package/docs/architecture/task-protocol-support.md +13 -13
  155. package/docs/architecture/terminal-product-route.md +1 -1
  156. package/docs/contracts/run-bundle.md +90 -7
  157. package/docs/contracts/schemas.md +39 -22
  158. package/docs/contracts/study-analysis.md +45 -0
  159. package/docs/contracts/study-costs.md +8 -0
  160. package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
  161. package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
  162. package/docs/principles/invariants-and-defaults.md +11 -8
  163. package/package.json +5 -3
  164. package/skills/humanish/SKILL.md +18 -4
@@ -60,12 +60,65 @@ function redactLocalPaths(text, label) {
60
60
  * passes only this value at a sandbox-id key.
61
61
  */
62
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`. */
63
+ /** The keys run writers put a sandbox id under; resource entries hold one as `id`. */
64
64
  const SANDBOX_ID_KEYS = new Set(["sandboxId", "subjectSandboxId"]);
65
+ /** Arrays of sandbox records: run.json's `providerResources` and cleanup.json's `resources`. */
66
+ const RESOURCE_ARRAY_KEYS = new Set(["providerResources", "resources"]);
67
+ /** A sandbox id's public stand-in: its digest matches a receipt without naming the sandbox. */
68
+ export function sandboxIdDigest(id) {
69
+ return digestText(id, 16);
70
+ }
71
+ /** The digest field written beside a redacted id: `idDigest`, `sandboxIdDigest`, … */
72
+ const digestKey = (key) => `${key}Digest`;
73
+ /**
74
+ * The entries of `object` with each raw id at one of `keys` replaced by REDACTED_SANDBOX_ID and its
75
+ * digest written beside it, unless the object already records one.
76
+ */
77
+ function redactIdEntries(object, keys) {
78
+ const entries = Object.entries(object);
79
+ const present = new Set(entries.map(([key]) => key));
80
+ return entries.flatMap(([key, child]) => {
81
+ if (!keys.has(key) || typeof child !== "string" || child === REDACTED_SANDBOX_ID)
82
+ return [[key, child]];
83
+ // A label that a text scrub left at the key already carries the id's digest.
84
+ const digest = SANDBOX_ID_LABEL.exec(child)?.[1] ?? sandboxIdDigest(child);
85
+ return [
86
+ [key, REDACTED_SANDBOX_ID],
87
+ ...(present.has(digestKey(key)) ? [] : [[digestKey(key), digest]]),
88
+ ];
89
+ });
90
+ }
91
+ /** How free text names a sandbox: the marker with the id's digest inside it. */
92
+ const SANDBOX_ID_LABEL = /^\[redacted-sandbox-id ([0-9a-f]{16})\]$/;
93
+ const RESOURCE_ID_KEYS = new Set(["id"]);
94
+ /** A raw id at a sandbox-id key: neither the marker nor a label that names its digest. */
95
+ const isRawSandboxId = (value) => typeof value === "string" && value !== REDACTED_SANDBOX_ID && !SANDBOX_ID_LABEL.test(value);
96
+ /** The raw ids at the keys redactSandboxIds replaces, anywhere in `value`. */
97
+ export function collectSandboxIds(value, into = new Set()) {
98
+ if (Array.isArray(value)) {
99
+ for (const item of value)
100
+ collectSandboxIds(item, into);
101
+ return into;
102
+ }
103
+ if (value === null || typeof value !== "object")
104
+ return into;
105
+ for (const [key, child] of Object.entries(value)) {
106
+ if (SANDBOX_ID_KEYS.has(key) && isRawSandboxId(child))
107
+ into.add(child);
108
+ if (RESOURCE_ARRAY_KEYS.has(key) && Array.isArray(child))
109
+ for (const resource of child) {
110
+ const id = resource !== null && typeof resource === "object" ? Reflect.get(resource, "id") : null;
111
+ if (isRawSandboxId(id))
112
+ into.add(id);
113
+ }
114
+ collectSandboxIds(child, into);
115
+ }
116
+ return into;
117
+ }
65
118
  /**
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.
119
+ * `value` with every sandbox id replaced by REDACTED_SANDBOX_ID plus its digest: strings at
120
+ * `sandboxId` and `subjectSandboxId`, and the `id` of each `providerResources` or `resources` entry. Raw ids live
121
+ * only in a run's sandbox-receipts.ndjson; every record and output names a sandbox by digest.
69
122
  */
70
123
  export function redactSandboxIds(value) {
71
124
  if (Array.isArray(value)) {
@@ -74,28 +127,19 @@ export function redactSandboxIds(value) {
74
127
  }
75
128
  if (value === null || typeof value !== "object")
76
129
  return value;
77
- let changed = false;
78
130
  // 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];
131
+ const entries = redactIdEntries(value, SANDBOX_ID_KEYS).map(([key, child]) => {
132
+ if (RESOURCE_ARRAY_KEYS.has(key) && Array.isArray(child))
133
+ return [
134
+ key,
135
+ child.map((resource) => resource !== null && typeof resource === "object" && !Array.isArray(resource)
136
+ ? Object.fromEntries(redactIdEntries(redactSandboxIds(resource), RESOURCE_ID_KEYS))
137
+ : redactSandboxIds(resource)),
138
+ ];
139
+ return [key, redactSandboxIds(child)];
98
140
  });
141
+ const changed = entries.length !== Object.keys(value).length ||
142
+ entries.some(([key, child]) => child !== value[key]);
99
143
  return changed ? Object.fromEntries(entries) : value;
100
144
  }
101
145
  /** Redact secrets to [REDACTED_SECRET] and local paths to their path labels. */
@@ -8,6 +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 { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
11
12
  import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
12
13
  import { decodeEscapes } from "../evidence/encoded-text.js";
13
14
  import { readPlainText } from "../evidence/plain-text.js";
@@ -300,6 +301,7 @@ function keptPngPaths(source, bundle) {
300
301
  async function writeDerivative(source, stagePaths, bundle) {
301
302
  const entries = [];
302
303
  const imagePaths = keptPngPaths(source, bundle);
304
+ const sandboxIds = inventorySandboxIds(source.files);
303
305
  let images = 0;
304
306
  for (const file of source.files) {
305
307
  const isPng = path.extname(file.path).toLowerCase() === ".png";
@@ -343,6 +345,7 @@ async function writeDerivative(source, stagePaths, bundle) {
343
345
  }
344
346
  bytes = jsonBytes(transformed);
345
347
  }
348
+ bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
346
349
  action = bytes.equals(file.bytes) ? "copied" : "updated";
347
350
  }
348
351
  await writeContainedOutputFile(stagePaths, file.path, bytes);
@@ -496,7 +499,9 @@ async function verifyFrozenSource(workspace, source, runId) {
496
499
  verified.shareSafety.reasons.every(
497
500
  // Both are repaired in the copy: frames are blurred, and an unread PNG is dropped or
498
501
  // refused by keptPngPaths. Any other unread file fails as an unsupported format.
499
- (reason) => reason.code === "RAW_SCREENSHOTS" || reason.code === "UNSCANNED_ARTIFACT")))) {
502
+ (reason) => reason.code === "RAW_SCREENSHOTS" ||
503
+ reason.code === "RAW_SANDBOX_ID" ||
504
+ reason.code === "UNSCANNED_ARTIFACT")))) {
500
505
  return { shareSafety: verified.shareSafety };
501
506
  }
502
507
  const loaded = await loadRunBundlePrepared(rawSnapshot.physicalPath, snapshotPaths);
@@ -384,6 +384,9 @@ function shareSafetyRemedy(codes) {
384
384
  if (codes.includes("RAW_SCREENSHOTS")) {
385
385
  steps.push("Re-run with policies.redactScreenshots: true to blur screenshots at capture.");
386
386
  }
387
+ if (codes.includes("RAW_SANDBOX_ID")) {
388
+ steps.push("Export with --format bundle --redact-screenshots, which writes a copy without raw sandbox ids, and share from that copy.");
389
+ }
387
390
  if (codes.includes("UNSCANNED_ARTIFACT")) {
388
391
  steps.push("verify cannot read the files that `humanish verify --json` lists under UNSCANNED_ARTIFACT as text: remove them from the run folder, or keep only images an actor trace references as stream screenshots.");
389
392
  }
@@ -11,5 +11,9 @@ export interface EnvFileLoadResult {
11
11
  message: string;
12
12
  };
13
13
  }
14
+ /** Records in `env` the names a `--dotenv` file set there. */
15
+ export declare function recordDotenvNames(env: NodeJS.ProcessEnv, names: readonly string[]): void;
16
+ /** The names `--dotenv` set in `env`, as recordDotenvNames recorded them. */
17
+ export declare function dotenvSetNames(env: Readonly<Record<string, string | undefined>>): Set<string>;
14
18
  export declare function loadEnvFile(cwd: string, envFile: string, env?: NodeJS.ProcessEnv): Promise<EnvFileLoadResult>;
15
19
  export {};
@@ -2,6 +2,23 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  const ENV_FILE_RESULT_SCHEMA = "humanish.env-file-result.v1";
4
4
  const envNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
5
+ /**
6
+ * Names a `--dotenv` file set, kept as a comma list in the environment the values went into, so a
7
+ * process humanish starts with that environment (a run the TUI launches) knows them too. A
8
+ * host-side participant (the Claude Code local agent) never gets these names, even ones it would
9
+ * otherwise keep, such as a proxy.
10
+ */
11
+ const DOTENV_NAMES = "HUMANISH_DOTENV_NAMES";
12
+ /** Records in `env` the names a `--dotenv` file set there. */
13
+ export function recordDotenvNames(env, names) {
14
+ if (names.length === 0)
15
+ return;
16
+ env[DOTENV_NAMES] = [...new Set([...dotenvSetNames(env), ...names])].join(",");
17
+ }
18
+ /** The names `--dotenv` set in `env`, as recordDotenvNames recorded them. */
19
+ export function dotenvSetNames(env) {
20
+ return new Set((env[DOTENV_NAMES] ?? "").split(",").filter((name) => name.length > 0));
21
+ }
5
22
  export async function loadEnvFile(cwd, envFile, env = process.env) {
6
23
  const resolvedCwd = path.resolve(cwd);
7
24
  const envPath = path.resolve(resolvedCwd, envFile);
@@ -22,6 +22,11 @@ export declare function discoverProviderKeys(args: {
22
22
  deps?: KeyResolutionDeps;
23
23
  announced?: ReadonlySet<string>;
24
24
  }): Promise<ResolvedKeyFill[]>;
25
+ /** What `--dotenv` loaded: the path as given, and the names it set because they were unset. */
26
+ export interface DotenvLoad {
27
+ path: string;
28
+ names: readonly string[];
29
+ }
25
30
  export interface KeySourceProbe {
26
31
  name: string;
27
32
  /** The source that supplies the key right now, or null when missing everywhere. */
@@ -43,6 +48,8 @@ export declare function probeKeySources(names: readonly string[], args: {
43
48
  cwd: string;
44
49
  env: NodeJS.ProcessEnv;
45
50
  deps?: KeyResolutionDeps;
51
+ /** The names `--dotenv` put into `env`, so their rows name the file, not the process env. */
52
+ dotenv?: DotenvLoad;
46
53
  }): Promise<KeySourceProbe[]>;
47
54
  /** Resolve a `humanish keys set` target: a vendor alias or a raw env name. Null = invalid. */
48
55
  export declare function resolveKeyName(vendorOrName: string): string | null;
@@ -1,8 +1,8 @@
1
1
  // Provider-key discovery: resolve the keys a live run needs through each vendor's
2
- // native chain instead of demanding a per-repo --env-file that operators hand-copy keys into.
2
+ // native chain instead of demanding a per-repo --dotenv that operators hand-copy keys into.
3
3
  //
4
4
  // The chain, per key, fill-only (a rung never overrides anything already present):
5
- // 1. process env, including whatever --env-file just loaded (explicit always wins);
5
+ // 1. process env, including whatever --dotenv just loaded (explicit always wins);
6
6
  // 2. `.humanish/local/provider.env`, the project-local overlay this CLI's own --help
7
7
  // examples document;
8
8
  // 3. the owning vendor's native store, where one exists:
@@ -24,7 +24,7 @@ import { loadEnvFile } from "./env-file.js";
24
24
  /** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
25
25
  * else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
26
26
  * must never enter process env off a file the operator did not explicitly pass (an explicit
27
- * --env-file remains the operator's own full-file load). */
27
+ * --dotenv remains the operator's own full-file load). */
28
28
  const KNOWN_PROVIDER_KEYS = [
29
29
  "OPENAI_API_KEY",
30
30
  "ANTHROPIC_API_KEY",
@@ -213,7 +213,7 @@ export async function discoverProviderKeys(args) {
213
213
  if (announced === undefined || announced.has(fill_.name))
214
214
  announce(`humanish keys: ${fill_.name} from ${fill_.source}`);
215
215
  for (const name of ignored)
216
- announce(`humanish keys: ignored ${name}, which is not a provider key. Discovery loads provider keys only; pass the file with --env-file to load every name in it.`);
216
+ announce(`humanish keys: ignored ${name}, which is not a provider key. Discovery loads provider keys only; pass the file with --dotenv to load every name in it.`);
217
217
  return fills;
218
218
  }
219
219
  /** True when the file's parent directory is (or traverses) a symlink at its last component:
@@ -253,7 +253,7 @@ export function describeMissingKeys(names, env) {
253
253
  const hints = names.map((name) => `${name}: ${missingKeyHint(name)}`).join("; ");
254
254
  const strict = env.HUMANISH_STRICT_KEYS?.trim() === "1";
255
255
  const chain = strict
256
- ? "key discovery is disabled (HUMANISH_STRICT_KEYS=1); only process env and --env-file are read"
256
+ ? "key discovery is disabled (HUMANISH_STRICT_KEYS=1); only process env and --dotenv are read"
257
257
  : `also checked ${PROJECT_OVERLAY_RELATIVE}, ~/.e2b/config.json, gh auth token, and ${path.join("~", ".config", "humanish", "keys.env")}`;
258
258
  return `${chain}. Fill: ${hints}.`;
259
259
  }
@@ -278,7 +278,9 @@ export async function probeKeySources(names, args) {
278
278
  args.env.GITHUB_TOKEN !== undefined &&
279
279
  args.env.GITHUB_TOKEN.trim() !== "";
280
280
  const source = inEnv
281
- ? "process env"
281
+ ? args.dotenv?.names.includes(name)
282
+ ? `--dotenv ${args.dotenv.path}`
283
+ : "process env"
282
284
  : aliasInEnv
283
285
  ? "process env (GITHUB_TOKEN)"
284
286
  : (bySource.get(name) ?? null);
@@ -307,7 +309,7 @@ export function setUserKey(name, value, env, deps = {}) {
307
309
  throw new Error(`Not a valid env name: ${name}`);
308
310
  }
309
311
  if (!PROVIDER_KEY_SET.has(name)) {
310
- throw new Error(`The store holds provider keys only (${[...PROVIDER_KEY_SET].join(", ")}). For anything else, use an explicit --env-file.`);
312
+ throw new Error(`The store holds provider keys only (${[...PROVIDER_KEY_SET].join(", ")}). For anything else, use an explicit --dotenv.`);
311
313
  }
312
314
  const storePath = userKeyStorePath(env, deps);
313
315
  const dir = path.dirname(storePath);
@@ -325,7 +327,7 @@ export function setUserKey(name, value, env, deps = {}) {
325
327
  // would silently resolve to a different secret on the next run.
326
328
  const roundTrip = parseStoreLine(`${name}=${trimmed}`);
327
329
  if (roundTrip === null || roundTrip[0] !== name || roundTrip[1] !== trimmed) {
328
- throw new Error("The value does not round-trip the store format (avoid leading quotes and '#'); pass it via --env-file instead.");
330
+ throw new Error("The value does not round-trip the store format (avoid leading quotes and '#'); pass it via --dotenv instead.");
329
331
  }
330
332
  writeStore(storePath, entries);
331
333
  return { path: storePath };
@@ -1,5 +1,6 @@
1
1
  import { type ActorEnding } from "../actors/stop-cause.js";
2
2
  import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
3
+ import { type RunDisplay } from "../run/display.js";
3
4
  import type { RunStream, RunStreamKind } from "../run/streams.js";
4
5
  export declare const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v1";
5
6
  interface ObserverArtifactLink {
@@ -20,7 +21,14 @@ export interface ObserverData {
20
21
  run: {
21
22
  runId: string;
22
23
  mode: RunBundle["mode"];
24
+ /** The participants' verdict. `display` says whether the run passed. */
23
25
  status: RunBundle["review"]["verdict"];
26
+ /**
27
+ * How the run reads (runDisplay in src/run/display.ts): `passed` only when the verdict is pass
28
+ * and run.json's outcome does not say the run failed. Absent in observer-data.json written
29
+ * before it existed.
30
+ */
31
+ display?: RunDisplay;
24
32
  title: string;
25
33
  createdAt: string;
26
34
  simCount: number;
@@ -109,7 +117,12 @@ interface ObserverLaneGroup {
109
117
  surface?: string;
110
118
  caseGroup?: string;
111
119
  }
112
- export declare function buildObserverData(bundle: RunBundle, generatedAt?: string): ObserverData;
120
+ /**
121
+ * The Observer's projection of a bundle. `statusRecord` is the run's status.json, read for a
122
+ * bundle with no outcome in run.json: a run in progress, interrupted, or recorded before run.json
123
+ * carried its outcome.
124
+ */
125
+ export declare function buildObserverData(bundle: RunBundle, generatedAt?: string, statusRecord?: unknown): ObserverData;
113
126
  /** Refresh presentation from recorded actor evidence, including older exported snapshots. */
114
127
  export declare function withObserverEndings(data: ObserverData): ObserverData;
115
128
  export {};
@@ -2,6 +2,7 @@ import { actorEnding } from "../actors/stop-cause.js";
2
2
  import { isCommsReceivingEvidence, receivingAnalysisContext } from "../comms/receiving-evidence.js";
3
3
  import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetails, withCuaReviewProvenance, } from "../run/outcomes.js";
4
4
  import { cuaGoalSource, CUA_COMPLETION_NOTE } from "../actors/goal-source.js";
5
+ import { bundleDisplayFacts, runDisplay } from "../run/display.js";
5
6
  export const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v1";
6
7
  /** Discard a forged or stale runtime grant before projecting persisted evidence. */
7
8
  export function recordedStreamEmbed(embed) {
@@ -19,7 +20,12 @@ const allKinds = [
19
20
  "artifact",
20
21
  "summary",
21
22
  ];
22
- export function buildObserverData(bundle, generatedAt = new Date().toISOString()) {
23
+ /**
24
+ * The Observer's projection of a bundle. `statusRecord` is the run's status.json, read for a
25
+ * bundle with no outcome in run.json: a run in progress, interrupted, or recorded before run.json
26
+ * carried its outcome.
27
+ */
28
+ export function buildObserverData(bundle, generatedAt = new Date().toISOString(), statusRecord) {
23
29
  const byKind = Object.fromEntries(allKinds.map((kind) => [kind, 0]));
24
30
  const events = [...(bundle.events ?? [])];
25
31
  // Analysis cites this source-backed aggregate as context, not a participant
@@ -32,14 +38,17 @@ export function buildObserverData(bundle, generatedAt = new Date().toISOString()
32
38
  const streams = (bundle.streams ?? []).map((stream) => {
33
39
  const sim = bundle.simulations.find((candidate) => candidate.id === stream.simId) ??
34
40
  fallbackSimulation(bundle, stream);
35
- // A natural session can finish its protocol while the participant explicitly reports a
36
- // blocker. Match the review's typed-outcome rule without rewriting the raw trace or
37
- // guessing from prose. Never turn an active or failed harness into a participant outcome.
38
- const status = (stream.status === "passed" || stream.status === "complete") &&
39
- stream.actor?.completionReason === "goal_satisfied" &&
40
- stream.actor.declaredOutcome === "blocked"
41
- ? "blocked"
42
- : stream.status;
41
+ // The judge's status for the participant wins where the run recorded one, so a participant
42
+ // the verdict counted as blocked or as never engaging shows that way here. A bundle written
43
+ // before the judge's status was recorded keeps the typed-outcome reading: a natural session
44
+ // that finished while declaring a blocker. Never turn an active or failed harness into a
45
+ // participant outcome.
46
+ const status = stream.judgedStatus ??
47
+ ((stream.status === "passed" || stream.status === "complete") &&
48
+ stream.actor?.completionReason === "goal_satisfied" &&
49
+ stream.actor.declaredOutcome === "blocked"
50
+ ? "blocked"
51
+ : stream.status);
43
52
  byKind[stream.kind] += 1;
44
53
  return {
45
54
  ...stream,
@@ -77,6 +86,7 @@ export function buildObserverData(bundle, generatedAt = new Date().toISOString()
77
86
  runId: bundle.runId,
78
87
  mode: bundle.mode,
79
88
  status: bundle.review.verdict,
89
+ display: runDisplay(bundleDisplayFacts(bundle, statusRecord)),
80
90
  title: `${bundle.scenario.title} - ${bundle.persona.name}`,
81
91
  createdAt: bundle.createdAt,
82
92
  simCount: bundle.simCount ?? bundle.simulations.length,
@@ -1,3 +1,4 @@
1
+ import type { RunDisplay } from "../run/display.js";
1
2
  export interface LibraryHistory {
2
3
  latestRunId: string | null;
3
4
  runs: Array<{
@@ -5,7 +6,10 @@ export interface LibraryHistory {
5
6
  createdAt: string | null;
6
7
  mode: string | null;
7
8
  href: string;
9
+ /** The participants' verdict. `display` says whether the run passed. */
8
10
  status: string;
11
+ /** How the run reads (runDisplay in src/run/display.ts); absent when its data could not be read. */
12
+ display?: RunDisplay;
9
13
  /** Current contained status-record observation, separate from the evidence verdict. */
10
14
  runtimeState?: "running" | "finished" | "interrupted" | "unknown";
11
15
  streamCount: number;
@@ -1,9 +1,10 @@
1
- const STATUS_TONES = {
2
- passed: "#3fb970",
3
- running: "#4f8ff7",
4
- failed: "#e5534b",
5
- blocked: "#d29922",
6
- timed_out: "#d29922",
1
+ // The pip color for each RunDisplay tone; a run with no display gets the neutral gray.
2
+ const DISPLAY_TONES = {
3
+ pass: "#3fb970",
4
+ live: "#4f8ff7",
5
+ fail: "#e5534b",
6
+ warn: "#d29922",
7
+ neutral: "#57606f",
7
8
  };
8
9
  export function renderLibraryHtml(history, opts) {
9
10
  const emptyState = opts.safe
@@ -83,7 +84,7 @@ main { padding: 1rem 1.1rem 3rem; max-width: 44rem; margin: 0 auto; }
83
84
  }
84
85
  // prose-check: script (the library page's JavaScript)
85
86
  function libraryClientJs() {
86
- const tones = JSON.stringify(STATUS_TONES);
87
+ const tones = JSON.stringify(DISPLAY_TONES);
87
88
  return `
88
89
  (function () {
89
90
  var TONES = ${tones};
@@ -103,9 +104,11 @@ function libraryClientJs() {
103
104
  link.href = run.href;
104
105
  var top = document.createElement("div");
105
106
  top.className = "run-top";
107
+ var display = run.display || null;
108
+ var live = display !== null && display.state === "running";
106
109
  var pip = document.createElement("span");
107
- pip.className = run.status === "running" ? "pip running" : "pip";
108
- pip.style.background = TONES[run.status] || "#57606f";
110
+ pip.className = live ? "pip running" : "pip";
111
+ pip.style.background = (display && TONES[display.tone]) || TONES.neutral;
109
112
  var id = document.createElement("span");
110
113
  id.className = "run-id";
111
114
  id.textContent = run.runId;
@@ -116,15 +119,15 @@ function libraryClientJs() {
116
119
  latest.className = "badge latest";
117
120
  latest.textContent = "latest";
118
121
  top.appendChild(latest);
119
- } else if (run.status === "running") {
120
- var live = document.createElement("span");
121
- live.className = "badge";
122
- live.textContent = "live";
123
- top.appendChild(live);
122
+ } else if (live) {
123
+ var badge = document.createElement("span");
124
+ badge.className = "badge";
125
+ badge.textContent = "live";
126
+ top.appendChild(badge);
124
127
  }
125
128
  var meta = document.createElement("div");
126
129
  meta.className = "run-meta";
127
- var metaBits = [run.mode || "unknown", run.streamCount + " participants", run.createdAt || ""];
130
+ var metaBits = [display ? display.label : "", run.mode || "unknown", run.streamCount + " participants", run.createdAt || ""];
128
131
  // Labeled cost token: ALWAYS "~$X est." (never a bare "$X"), so the library never implies an
129
132
  // authoritative charge. Null = omitted (advisory, fail-open on display).
130
133
  if (run.costLabel) {
@@ -11,7 +11,8 @@ import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
11
11
  import { loadAnalysis } from "../analysis/load.js";
12
12
  import { physicalCwdOf, runIdOf, bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
13
13
  import { writeContainedOutputFile } from "../run/contained-output.js";
14
- import { loadRunBundlePrepared } from "../run/locate.js";
14
+ import { loadRunBundlePrepared, readRunJsonIfExists } from "../run/locate.js";
15
+ import { RUN_STATUS_FILE } from "../run/status.js";
15
16
  import { verifyRunPrepared } from "../verify/verify.js";
16
17
  import { renderObserverHtml } from "./artifact.js";
17
18
  import { buildObserverData } from "./data.js";
@@ -99,7 +100,12 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
99
100
  ...projectShareCheckedAnalysis(await loadAnalysis(preparedRunPaths)),
100
101
  ...(spend === null ? {} : { spend }),
101
102
  };
102
- const observerData = buildObserverData(loaded.bundle);
103
+ // A run.json without an outcome is in progress, interrupted, or older than the field: its
104
+ // status.json says which, and holds an older run's ok.
105
+ const statusRecord = loaded.bundle.outcome === undefined
106
+ ? await readRunJsonIfExists(preparedRunPaths, RUN_STATUS_FILE)
107
+ : undefined;
108
+ const observerData = buildObserverData(loaded.bundle, undefined, statusRecord);
103
109
  observerData.publicSafety.share = {
104
110
  status: verified.shareSafety.status,
105
111
  verifiedAt: new Date().toISOString(),
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { RunDisplay } from "../run/display.js";
2
3
  import { type ObserverData } from "./data.js";
3
4
  import { type PinnedDirectory } from "./pinned-files.js";
4
5
  export interface ObserverRuntimeStreamUrl {
@@ -22,6 +23,7 @@ export declare function buildHistoryIndex(proofRoot: PinnedDirectory, admitRun?:
22
23
  mode: string | null;
23
24
  href: string;
24
25
  status: string;
26
+ display?: RunDisplay;
25
27
  runtimeState?: NonNullable<ObserverData["runtime"]>["state"];
26
28
  streamCount: number;
27
29
  estimatedCostUsd: number | null;
@@ -8,7 +8,7 @@ import { listRuns } from "../run/stored-runs.js";
8
8
  import { readAnalysisAccounting } from "../run/costs.js";
9
9
  import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
10
10
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
11
- import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord } from "../run/status.js";
11
+ import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
12
12
  import { renderObserverHtml } from "./artifact.js";
13
13
  import { buildObserverData, recordedStreamEmbed, withObserverEndings, } from "./data.js";
14
14
  import { buildArtifactSecurityHeaders } from "./http.js";
@@ -120,6 +120,7 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
120
120
  }
121
121
  }
122
122
  async function readObserverData(runRoot, runtimeStreamUrls = []) {
123
+ const record = await readLocalRunStatus(runRoot);
123
124
  // Best-effort load from either source. Both reads swallow all errors on
124
125
  // purpose: this runs on every browser poll of a live run, where run.json may
125
126
  // be absent, still being written (a partial-JSON parse error), or superseded
@@ -130,57 +131,64 @@ async function readObserverData(runRoot, runtimeStreamUrls = []) {
130
131
  if (!bundleBytes)
131
132
  throw new Error("run.json unavailable");
132
133
  const bundle = JSON.parse(bundleBytes.toString("utf8"));
133
- return withRuntimeStreamUrls(await withLocalRunStatus(runRoot, buildObserverData(bundle)), runtimeStreamUrls);
134
+ return withRuntimeStreamUrls(withLocalRunStatus(record, buildObserverData(bundle, undefined, record)), runtimeStreamUrls);
134
135
  }
135
136
  catch { }
136
137
  try {
137
138
  const observerBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "observer", "observer-data.json"));
138
139
  if (!observerBytes)
139
140
  throw new Error("observer-data.json unavailable");
140
- return withRuntimeStreamUrls(await withLocalRunStatus(runRoot, withObserverEndings(JSON.parse(observerBytes.toString("utf8")))), runtimeStreamUrls);
141
+ return withRuntimeStreamUrls(withLocalRunStatus(record, withObserverEndings(JSON.parse(observerBytes.toString("utf8")))), runtimeStreamUrls);
141
142
  }
142
143
  catch { }
143
144
  return null;
144
145
  }
145
- /**
146
- * Liveness is a current read of a contained local status record, separate from run evidence.
147
- * A stale heartbeat means unknown: neither an old timestamp nor a persisted PID proves that a
148
- * process died (the evidence may have been copied from another machine). No PID is served/probed.
149
- */
150
- async function withLocalRunStatus(runRoot, input) {
151
- // A served observation must never be inherited from a persisted projection or export.
152
- const { runtime: _persistedRuntime, ...data } = input;
146
+ /** The run directory's own status record, when it is well formed and names that directory. */
147
+ async function readLocalRunStatus(runRoot) {
153
148
  try {
154
149
  const bytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, RUN_STATUS_FILE));
155
150
  if (!bytes)
156
- return data;
151
+ return undefined;
157
152
  const record = JSON.parse(bytes.toString("utf8"));
158
- if (!isRunStatusRecord(record) ||
159
- record.runId !== data.run.runId ||
160
- record.mode !== data.run.mode ||
161
- record.runId !== path.basename(runRoot.physicalPath))
162
- return data;
163
- const now = Date.now();
164
- const started = Date.parse(record.startedAt);
165
- const updated = Date.parse(record.updatedAt);
166
- const timestampsValid = Number.isFinite(started) && Number.isFinite(updated) && started <= updated && updated <= now;
167
- let state = "unknown";
168
- if (timestampsValid) {
169
- if (record.state === "finished") {
170
- state = "finished";
171
- }
172
- else if (record.state === "running" && now - updated <= RUN_STATUS_STALE_MS) {
173
- state = "running";
174
- }
175
- }
176
- return {
177
- ...data,
178
- runtime: { state, observedAt: new Date(now).toISOString(), source: "local-run-status" },
179
- };
153
+ return isRunStatusRecord(record) && record.runId === path.basename(runRoot.physicalPath)
154
+ ? record
155
+ : undefined;
180
156
  }
181
157
  catch {
158
+ return undefined;
159
+ }
160
+ }
161
+ /**
162
+ * Liveness is a current read of a contained local status record, separate from run evidence.
163
+ * A stale heartbeat means unknown: neither an old timestamp nor a persisted PID proves that a
164
+ * process died (the evidence may have been copied from another machine). A record its stopped
165
+ * process wrote is interrupted. No PID is served/probed.
166
+ */
167
+ function withLocalRunStatus(record, input) {
168
+ // A served observation must never be inherited from a persisted projection or export.
169
+ const { runtime: _persistedRuntime, ...data } = input;
170
+ if (record === undefined || record.runId !== data.run.runId || record.mode !== data.run.mode)
182
171
  return data;
172
+ const now = Date.now();
173
+ const started = Date.parse(record.startedAt);
174
+ const updated = Date.parse(record.updatedAt);
175
+ const timestampsValid = Number.isFinite(started) && Number.isFinite(updated) && started <= updated && updated <= now;
176
+ let state = "unknown";
177
+ if (timestampsValid) {
178
+ if (record.state === "finished") {
179
+ state = "finished";
180
+ }
181
+ else if (record.state === "interrupted") {
182
+ state = "interrupted";
183
+ }
184
+ else if (record.state === "running" && now - updated <= RUN_STATUS_STALE_MS) {
185
+ state = "running";
186
+ }
183
187
  }
188
+ return {
189
+ ...data,
190
+ runtime: { state, observedAt: new Date(now).toISOString(), source: "local-run-status" },
191
+ };
184
192
  }
185
193
  /** internal: exported for the stream lifecycle tests. */
186
194
  export function withRuntimeStreamUrls(data, runtimeStreamUrls) {
@@ -249,6 +257,7 @@ admitRun) {
249
257
  mode: admitRun ? (data?.run.mode ?? null) : run.mode,
250
258
  href: `/_humanish/runs/${encodeURIComponent(run.runId)}/observer/index.html`,
251
259
  status: data?.run.status ?? "unknown",
260
+ ...(data?.run.display === undefined ? {} : { display: data.run.display }),
252
261
  ...(data?.runtime ? { runtimeState: data.runtime.state } : {}),
253
262
  streamCount: data?.streams.length ?? 0,
254
263
  // Labeled run-total cost estimate (advisory; null when the run carries no cost summary).