humanish 0.111.4 → 0.113.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 (158) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +11 -0
  3. package/TELEMETRY.md +21 -0
  4. package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
  5. package/dist/actors/codex/restricted-participant-policy.js +20 -2
  6. package/dist/actors/codex/restricted-participant.d.ts +1 -1
  7. package/dist/actors/codex/restricted-participant.js +2 -1
  8. package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
  9. package/dist/actors/computer-use/loop/debrief.js +57 -20
  10. package/dist/actors/computer-use/loop/ending.d.ts +5 -2
  11. package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
  12. package/dist/actors/computer-use/loop/impressions.js +40 -0
  13. package/dist/actors/computer-use/loop/session.d.ts +3 -1
  14. package/dist/actors/computer-use/loop/session.js +2 -0
  15. package/dist/actors/computer-use/loop/trace.js +2 -0
  16. package/dist/actors/computer-use/loop/types.d.ts +8 -1
  17. package/dist/actors/computer-use/loop.d.ts +2 -1
  18. package/dist/actors/computer-use/loop.js +15 -3
  19. package/dist/actors/computer-use/openai-conversation.d.ts +96 -0
  20. package/dist/actors/computer-use/openai-conversation.js +238 -0
  21. package/dist/actors/computer-use/openai-provider.js +38 -48
  22. package/dist/actors/contract.d.ts +45 -3
  23. package/dist/actors/contract.js +13 -0
  24. package/dist/analysis/diagnostics.js +7 -12
  25. package/dist/analysis/evidence-sources.d.ts +3 -1
  26. package/dist/analysis/evidence-sources.js +10 -1
  27. package/dist/analysis/evidence.js +2 -2
  28. package/dist/analysis/execute.d.ts +1 -1
  29. package/dist/analysis/execute.js +37 -5
  30. package/dist/analysis/service.js +7 -1
  31. package/dist/analysis/types.d.ts +23 -0
  32. package/dist/analysis/validation.d.ts +80 -35
  33. package/dist/analysis/validation.js +58 -11
  34. package/dist/cli/commands/runs.js +37 -20
  35. package/dist/cli/commands/setup.js +95 -6
  36. package/dist/cli/commands/study-format.js +7 -4
  37. package/dist/cli/commands/study.js +1 -1
  38. package/dist/cli/commands/tui.js +1 -1
  39. package/dist/cli/doctor.d.ts +2 -0
  40. package/dist/cli/doctor.js +18 -0
  41. package/dist/cli/findings.d.ts +34 -3
  42. package/dist/cli/findings.js +136 -18
  43. package/dist/cli/io.d.ts +4 -1
  44. package/dist/cli/io.js +3 -2
  45. package/dist/cli/program.js +35 -1
  46. package/dist/cli/secret-prompt.d.ts +5 -1
  47. package/dist/cli/secret-prompt.js +6 -2
  48. package/dist/cli/update-check-worker.d.ts +1 -0
  49. package/dist/cli/update-check-worker.js +5 -0
  50. package/dist/cli/update-check.d.ts +46 -0
  51. package/dist/cli/update-check.js +203 -0
  52. package/dist/feedback/draft.js +32 -4
  53. package/dist/keys/key-resolution.js +2 -0
  54. package/dist/keys/key-status.d.ts +37 -0
  55. package/dist/keys/key-status.js +68 -0
  56. package/dist/observer/run-routes.js +8 -4
  57. package/dist/observer-app.html +4 -4
  58. package/dist/routes/computer-use/bundle.d.ts +1 -1
  59. package/dist/routes/computer-use/bundle.js +6 -16
  60. package/dist/routes/computer-use/fanout-bundle.js +40 -37
  61. package/dist/routes/computer-use/fanout-records.d.ts +2 -0
  62. package/dist/routes/computer-use/fanout-records.js +18 -26
  63. package/dist/routes/computer-use/local-vm.js +9 -2
  64. package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
  65. package/dist/routes/computer-use/participant-execution.js +13 -12
  66. package/dist/routes/computer-use/participant-facts.d.ts +2 -2
  67. package/dist/routes/computer-use/participant-facts.js +10 -1
  68. package/dist/routes/computer-use/rerun-selection.js +23 -21
  69. package/dist/routes/computer-use/result.js +10 -17
  70. package/dist/routes/computer-use/self-report.js +7 -1
  71. package/dist/routes/computer-use/setup.js +14 -0
  72. package/dist/routes/computer-use/single-bundle.d.ts +2 -0
  73. package/dist/routes/computer-use/single-bundle.js +81 -37
  74. package/dist/routes/computer-use/types.d.ts +4 -1
  75. package/dist/routes/scripted/bundle.d.ts +1 -1
  76. package/dist/routes/scripted/bundle.js +9 -19
  77. package/dist/routes/scripted/surface-records.d.ts +0 -1
  78. package/dist/routes/scripted/surface-records.js +27 -13
  79. package/dist/routes/shared-world/bundle-records.d.ts +2 -2
  80. package/dist/routes/shared-world/bundle-records.js +24 -30
  81. package/dist/routes/shared-world/bundle.d.ts +1 -1
  82. package/dist/routes/shared-world/bundle.js +71 -79
  83. package/dist/routes/shared-world/participant-specs.d.ts +1 -2
  84. package/dist/routes/shared-world/participant-specs.js +0 -8
  85. package/dist/routes/shared-world/result.js +8 -12
  86. package/dist/routes/terminal/bundle.d.ts +1 -1
  87. package/dist/routes/terminal/bundle.js +10 -19
  88. package/dist/routes/terminal/dry-run.js +1 -0
  89. package/dist/routes/terminal/live-sandbox.js +9 -2
  90. package/dist/routes/terminal/plan.js +1 -0
  91. package/dist/routes/terminal/runtime.d.ts +1 -0
  92. package/dist/routes/terminal/runtime.js +1 -0
  93. package/dist/routes/terminal/session.js +1 -0
  94. package/dist/run/costs.js +9 -20
  95. package/dist/run/detail.d.ts +1 -1
  96. package/dist/run/detail.js +13 -4
  97. package/dist/run/display.d.ts +2 -2
  98. package/dist/run/display.js +15 -18
  99. package/dist/run/dry-run.js +10 -25
  100. package/dist/run/judge.d.ts +24 -0
  101. package/dist/run/judge.js +54 -0
  102. package/dist/run/liveness.d.ts +18 -0
  103. package/dist/run/liveness.js +24 -0
  104. package/dist/run/outcomes.d.ts +8 -1
  105. package/dist/run/outcomes.js +58 -12
  106. package/dist/run/participant-caption.d.ts +31 -0
  107. package/dist/run/participant-caption.js +60 -0
  108. package/dist/run/participant-records.d.ts +4 -2
  109. package/dist/run/participant-records.js +6 -0
  110. package/dist/run/review-markdown.d.ts +11 -0
  111. package/dist/run/review-markdown.js +22 -0
  112. package/dist/run/run-cost.d.ts +7 -0
  113. package/dist/run/run-cost.js +25 -5
  114. package/dist/run/run-index.d.ts +4 -50
  115. package/dist/run/run-index.js +57 -87
  116. package/dist/run/run.d.ts +2 -2
  117. package/dist/run/run.js +5 -3
  118. package/dist/run/stats.js +2 -12
  119. package/dist/run/transient-comms-secrets.d.ts +8 -3
  120. package/dist/run/transient-comms-secrets.js +21 -1
  121. package/dist/run-study.js +17 -1
  122. package/dist/study/discover.js +6 -1
  123. package/dist/study/doctor.d.ts +2 -0
  124. package/dist/study/doctor.js +45 -7
  125. package/dist/study/init-templates.js +10 -5
  126. package/dist/study/keys.d.ts +1 -0
  127. package/dist/study/keys.js +1 -1
  128. package/dist/study/parse/execution.js +7 -1
  129. package/dist/study/plan-types.d.ts +1 -0
  130. package/dist/study/plan.js +3 -2
  131. package/dist/study/preflight-result.d.ts +1 -0
  132. package/dist/study/preflight-result.js +1 -1
  133. package/dist/study/preflight.d.ts +2 -0
  134. package/dist/study/preflight.js +54 -14
  135. package/dist/study/types.d.ts +4 -1
  136. package/dist/study/warnings.d.ts +7 -1
  137. package/dist/study/warnings.js +82 -4
  138. package/dist/substrates/local/capacity.d.ts +75 -0
  139. package/dist/substrates/local/capacity.js +143 -0
  140. package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
  141. package/dist/substrates/local/firecracker-desktop.js +34 -3
  142. package/dist/substrates/local/runtime-host.d.ts +16 -3
  143. package/dist/substrates/local/runtime-host.js +49 -4
  144. package/dist/substrates/local/runtime.d.ts +10 -0
  145. package/dist/substrates/local/runtime.js +44 -5
  146. package/dist/tui-app.js +75 -75
  147. package/dist/verify/liveness.d.ts +2 -2
  148. package/dist/verify/liveness.js +7 -4
  149. package/docs/architecture/actor-contract.md +20 -6
  150. package/docs/architecture/terminal-product-route.md +14 -0
  151. package/docs/contracts/feedback.md +8 -1
  152. package/docs/contracts/run-bundle.md +10 -0
  153. package/docs/contracts/schemas.md +65 -34
  154. package/docs/contracts/study-analysis.md +86 -22
  155. package/package.json +2 -1
  156. package/skills/humanish/SKILL.md +21 -10
  157. package/dist/actors/computer-use/openai-requests.d.ts +0 -62
  158. package/dist/actors/computer-use/openai-requests.js +0 -144
@@ -1,6 +1,10 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { Writable } from "node:stream";
3
- /** Host-owned entry: no secret crosses the TUI view contract or reaches a writable terminal. */
3
+ /**
4
+ * Host-owned entry: no secret crosses the TUI view contract or reaches a writable terminal.
5
+ * Resolves to the trimmed line, an empty string when Enter was pressed alone, or null when the
6
+ * person cancelled or there is no terminal.
7
+ */
4
8
  export async function promptSecret(label, stdin, stdout) {
5
9
  if (!stdin.isTTY || !stdout.isTTY)
6
10
  return null;
@@ -27,7 +31,7 @@ export async function promptSecret(label, stdin, stdout) {
27
31
  stdout.write("\n");
28
32
  resolve(value);
29
33
  };
30
- reader.once("line", (line) => finish(line.trim() || null));
34
+ reader.once("line", (line) => finish(line.trim()));
31
35
  reader.once("SIGINT", () => finish(null));
32
36
  reader.once("close", () => finish(null));
33
37
  reader.once("error", () => finish(null));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ // Started detached by startRefreshWorker in update-check.ts, with the cache path as its argument.
2
+ import { refreshUpdateCache } from "./update-check.js";
3
+ const cachePath = process.argv[2];
4
+ if (cachePath !== undefined)
5
+ await refreshUpdateCache({ cachePath });
@@ -0,0 +1,46 @@
1
+ export interface UpdateCheckContext {
2
+ /** The running CLI's version. */
3
+ installed: string;
4
+ env: NodeJS.ProcessEnv;
5
+ /** Whether stderr is a terminal. */
6
+ terminal: boolean;
7
+ /** Whether the command prints a JSON document. */
8
+ json: boolean;
9
+ /** Whether this CLI runs from humanish's own source checkout. */
10
+ ownCheckout: boolean;
11
+ /** The clock, in epoch milliseconds. */
12
+ now: number;
13
+ home?: string;
14
+ /** Starts the background registry request that writes the cache. Never awaited. */
15
+ startRefresh: (cachePath: string) => void;
16
+ }
17
+ /**
18
+ * The one line to print after a command when the cache names a newer humanish, at most once a day.
19
+ * Records that it was shown. Every failure inside is swallowed: an update notice is never a reason
20
+ * for a command to fail.
21
+ */
22
+ export declare function checkForUpdate(context: UpdateCheckContext): string | undefined;
23
+ /**
24
+ * The version the last successful check recorded, compared with `installed`, for `doctor`. Reads the
25
+ * cache only; undefined until a check has succeeded on this machine.
26
+ */
27
+ export declare function recordedVersion(installed: string, env: NodeJS.ProcessEnv, home?: string): {
28
+ newer: boolean;
29
+ message: string;
30
+ } | undefined;
31
+ /**
32
+ * Ask the npm registry for humanish's `latest` dist-tag and record it in the cache. The detached
33
+ * worker runs this. The request is a GET with an Accept header and nothing else: no machine id,
34
+ * installed version, command or path. A failed or malformed answer leaves the cache as it was.
35
+ */
36
+ export declare function refreshUpdateCache(args: {
37
+ cachePath: string;
38
+ fetchFn?: (url: string, init: RequestInit) => Promise<Response>;
39
+ now?: () => number;
40
+ timeoutMs?: number;
41
+ }): Promise<void>;
42
+ /**
43
+ * Start the worker that runs refreshUpdateCache, detached and unreferenced so this process exits
44
+ * without waiting for it. A source checkout has no built worker, and nothing starts.
45
+ */
46
+ export declare function startRefreshWorker(cachePath: string): void;
@@ -0,0 +1,203 @@
1
+ // Tells a person when a newer humanish is published, as npm, gh and the Vercel CLI do. A command
2
+ // only reads a small cache and prints at most one line from it; the registry request runs in a
3
+ // detached process that writes the cache for a later command, so the check never delays or fails
4
+ // the command that started it.
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { disabledByEnvironment } from "./telemetry.js";
11
+ const DAY_MS = 24 * 60 * 60 * 1000;
12
+ const REGISTRY_DIST_TAGS = "https://registry.npmjs.org/-/package/humanish/dist-tags";
13
+ /** Next to telemetry.json, under the same XDG rule the key store uses. */
14
+ function updateCachePath(env, home = homedir()) {
15
+ const declared = env.XDG_CONFIG_HOME?.trim();
16
+ const configHome = declared !== undefined && declared !== "" && path.isAbsolute(declared)
17
+ ? declared
18
+ : path.join(home, ".config");
19
+ return path.join(configHome, "humanish", "update-check.json");
20
+ }
21
+ function readUpdateCache(cachePath) {
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
24
+ if (parsed === null || typeof parsed !== "object")
25
+ return {};
26
+ const record = parsed;
27
+ const text = (field) => typeof record[field] === "string" ? record[field] : undefined;
28
+ const cache = {};
29
+ const checkedAt = text("checkedAt");
30
+ const latest = text("latest");
31
+ const notifiedAt = text("notifiedAt");
32
+ if (checkedAt !== undefined)
33
+ cache.checkedAt = checkedAt;
34
+ if (latest !== undefined && parseVersion(latest) !== undefined)
35
+ cache.latest = latest;
36
+ if (notifiedAt !== undefined)
37
+ cache.notifiedAt = notifiedAt;
38
+ return cache;
39
+ }
40
+ catch {
41
+ return {};
42
+ }
43
+ }
44
+ function writeUpdateCache(cachePath, cache) {
45
+ mkdirSync(path.dirname(cachePath), { recursive: true });
46
+ writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
47
+ }
48
+ function parseVersion(version) {
49
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
50
+ return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])];
51
+ }
52
+ /** True when `candidate` is a higher release than `installed`. */
53
+ function isNewer(candidate, installed) {
54
+ const a = parseVersion(candidate);
55
+ const b = parseVersion(installed);
56
+ if (a === undefined || b === undefined)
57
+ return false;
58
+ for (let index = 0; index < 3; index += 1)
59
+ if (a[index] !== b[index])
60
+ return a[index] > b[index];
61
+ return false;
62
+ }
63
+ /** True when `timestamp` is missing, unreadable, or at least a day before `now`. */
64
+ function dayPassed(timestamp, now) {
65
+ const then = timestamp === undefined ? Number.NaN : Date.parse(timestamp);
66
+ return Number.isNaN(then) || now - then >= DAY_MS;
67
+ }
68
+ const truthy = (value) => value !== undefined &&
69
+ value.trim() !== "" &&
70
+ value.trim() !== "0" &&
71
+ value.trim().toLowerCase() !== "false";
72
+ /**
73
+ * Only a person at a terminal is told, and only when nothing has switched the check off. A pipe,
74
+ * CI and a JSON document have a program reading them. A study participant runs the version the
75
+ * study chose, and a notice would send it to another. The telemetry switches turn this off too:
76
+ * someone who opted out of telemetry expects no unprompted requests.
77
+ */
78
+ function checkAllowed(context) {
79
+ const env = context.env;
80
+ return (context.terminal &&
81
+ !context.json &&
82
+ !context.ownCheckout &&
83
+ !truthy(env.CI) &&
84
+ !truthy(env.HUMANISH_NO_UPDATE_CHECK) &&
85
+ !truthy(env.HUMANISH_STUDY_PARTICIPANT) &&
86
+ !disabledByEnvironment(env));
87
+ }
88
+ /**
89
+ * The one line to print after a command when the cache names a newer humanish, at most once a day.
90
+ * Records that it was shown. Every failure inside is swallowed: an update notice is never a reason
91
+ * for a command to fail.
92
+ */
93
+ export function checkForUpdate(context) {
94
+ try {
95
+ if (!checkAllowed(context))
96
+ return undefined;
97
+ const cachePath = updateCachePath(context.env, context.home);
98
+ const cache = readUpdateCache(cachePath);
99
+ const stamp = new Date(context.now).toISOString();
100
+ const next = { ...cache };
101
+ // Stamped before the request starts, so an offline machine asks once a day rather than on
102
+ // every command. The request writes `latest` when it succeeds.
103
+ const refresh = dayPassed(cache.checkedAt, context.now);
104
+ if (refresh)
105
+ next.checkedAt = stamp;
106
+ const latest = cache.latest;
107
+ const notice = latest !== undefined &&
108
+ isNewer(latest, context.installed) &&
109
+ dayPassed(cache.notifiedAt, context.now)
110
+ ? `humanish ${context.installed} is out of date; the latest is ${latest}. Run npx humanish@latest to use it.`
111
+ : undefined;
112
+ if (notice !== undefined)
113
+ next.notifiedAt = stamp;
114
+ if (refresh || notice !== undefined)
115
+ writeUpdateCache(cachePath, next);
116
+ if (refresh)
117
+ context.startRefresh(cachePath);
118
+ return notice;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ /**
125
+ * The version the last successful check recorded, compared with `installed`, for `doctor`. Reads the
126
+ * cache only; undefined until a check has succeeded on this machine.
127
+ */
128
+ export function recordedVersion(installed, env, home) {
129
+ const cache = readUpdateCache(updateCachePath(env, home));
130
+ if (cache.latest === undefined)
131
+ return undefined;
132
+ const checked = cache.checkedAt === undefined ? "" : ` (checked ${cache.checkedAt.slice(0, 10)})`;
133
+ return isNewer(cache.latest, installed)
134
+ ? {
135
+ newer: true,
136
+ message: `${installed} installed; ${cache.latest} is the latest${checked}. Run npx humanish@latest to use it.`,
137
+ }
138
+ : { newer: false, message: `${installed} installed, the latest published${checked}` };
139
+ }
140
+ /**
141
+ * Ask the npm registry for humanish's `latest` dist-tag and record it in the cache. The detached
142
+ * worker runs this. The request is a GET with an Accept header and nothing else: no machine id,
143
+ * installed version, command or path. A failed or malformed answer leaves the cache as it was.
144
+ */
145
+ export async function refreshUpdateCache(args) {
146
+ try {
147
+ const fetchFn = args.fetchFn ?? globalThis.fetch;
148
+ const response = await fetchFn(REGISTRY_DIST_TAGS, {
149
+ headers: { accept: "application/json" },
150
+ signal: AbortSignal.timeout(args.timeoutMs ?? 3_000),
151
+ });
152
+ if (!response.ok)
153
+ return;
154
+ const body = await response.json();
155
+ const latest = body !== null && typeof body === "object"
156
+ ? body.latest
157
+ : undefined;
158
+ if (typeof latest !== "string" || parseVersion(latest) === undefined)
159
+ return;
160
+ const now = (args.now ?? Date.now)();
161
+ writeUpdateCache(args.cachePath, {
162
+ ...readUpdateCache(args.cachePath),
163
+ checkedAt: new Date(now).toISOString(),
164
+ latest,
165
+ });
166
+ }
167
+ catch {
168
+ // Offline, slow or refused: the next check is a day away either way.
169
+ }
170
+ }
171
+ /** What the worker's environment keeps: how this machine reaches the network, and no credentials. */
172
+ const WORKER_ENV_NAMES = [
173
+ "HTTPS_PROXY",
174
+ "HTTP_PROXY",
175
+ "NO_PROXY",
176
+ "https_proxy",
177
+ "http_proxy",
178
+ "no_proxy",
179
+ "NODE_USE_ENV_PROXY",
180
+ "NODE_EXTRA_CA_CERTS",
181
+ "SYSTEMROOT",
182
+ ];
183
+ /**
184
+ * Start the worker that runs refreshUpdateCache, detached and unreferenced so this process exits
185
+ * without waiting for it. A source checkout has no built worker, and nothing starts.
186
+ */
187
+ export function startRefreshWorker(cachePath) {
188
+ const worker = fileURLToPath(new URL("./update-check-worker.js", import.meta.url));
189
+ if (!existsSync(worker))
190
+ return;
191
+ const env = Object.fromEntries(WORKER_ENV_NAMES.flatMap((name) => {
192
+ const value = process.env[name];
193
+ return value === undefined ? [] : [[name, value]];
194
+ }));
195
+ const child = spawn(process.execPath, [worker, cachePath], {
196
+ detached: true,
197
+ stdio: "ignore",
198
+ windowsHide: true,
199
+ env,
200
+ });
201
+ child.on("error", () => { });
202
+ child.unref();
203
+ }
@@ -120,7 +120,8 @@ export async function buildAnalysisDraft(context, options) {
120
120
  const root = path.dirname(context.loaded.bundlePath);
121
121
  const evidenceIds = new Set(finding.observations.flatMap((item) => item.evidenceIds));
122
122
  const evidence = analysis.evidence.filter((item) => evidenceIds.has(item.id));
123
- const claim = correction?.status === "amended" ? correction.replacementClaim : finding.title;
123
+ const amended = correction?.status === "amended";
124
+ const claim = amended ? correction.replacementClaim : (finding.headline ?? finding.title);
124
125
  const firstLine = claim.trim().split(/\r?\n/)[0] || `Reviewed finding ${finding.id}`;
125
126
  const summary = Array.from(firstLine).length > 160
126
127
  ? Array.from(firstLine).slice(0, 159).join("") + "…"
@@ -144,13 +145,23 @@ export async function buildAnalysisDraft(context, options) {
144
145
  summary,
145
146
  expected: "Review the cited behavior against the participant assignment and confirm the expected product behavior.",
146
147
  actual: [
148
+ // The plain account leads; an amendment makes it stale, so the draft says so in its place.
149
+ ...(finding.headline === undefined
150
+ ? []
151
+ : amended
152
+ ? [
153
+ "This finding was corrected in human review. The reviewer's claim above replaces its original headline and account.",
154
+ "",
155
+ ]
156
+ : finding.experience === undefined
157
+ ? []
158
+ : [finding.experience, ""]),
147
159
  "Independent study analysis; does not replace participant feedback or recorded completion outcomes.",
148
- correction?.status === "amended"
149
- ? `Amended claim: ${claim}. Original analysis: ${finding.summary}`
150
- : finding.summary,
160
+ amended ? `Amended claim: ${claim}. Original analysis: ${finding.summary}` : finding.summary,
151
161
  `Impact: ${finding.impact}. ${finding.affectedStreamIds.length} affected / ${finding.exposedStreamIds.length} observed exposed participants. ${finding.exposureReason}`,
152
162
  `Recovery: ${finding.recovery}. Confidence: ${finding.confidence}.`,
153
163
  ...finding.observations.map((item) => `${item.basis}: ${item.claim}${item.limitation ? ` Limitation: ${item.limitation}` : ""}`),
164
+ ...citedImpressions(bundle, evidence).map(({ streamId, kind, text }) => `Participant ${streamId} said at the end (${kind.replaceAll("_", " ")}): ${text}`),
154
165
  `Next check: ${finding.nextStep}`,
155
166
  correction
156
167
  ? `Human review: ${correction.status}. ${correction.reason}`
@@ -196,6 +207,23 @@ export async function buildAnalysisDraft(context, options) {
196
207
  ],
197
208
  };
198
209
  }
210
+ /**
211
+ * The participant impressions among a finding's cited evidence. Only an impression whose text the
212
+ * cited entry carries whole is quoted, so the draft repeats nothing the analysis share check did
213
+ * not read.
214
+ */
215
+ function citedImpressions(bundle, evidence) {
216
+ return evidence.flatMap((item) => {
217
+ const impressions = bundle.streams.find((stream) => stream.id === item.streamId)?.actor
218
+ ?.impressions;
219
+ const impression = impressions?.status === "collected"
220
+ ? impressions.items.find((entry) => entry.messageId === item.eventId)
221
+ : undefined;
222
+ return impression === undefined || !item.text.includes(impression.text)
223
+ ? []
224
+ : [{ streamId: item.streamId, kind: impression.kind, text: impression.text }];
225
+ });
226
+ }
199
227
  export function isUsableFeedbackCandidate(candidate) {
200
228
  if (!isRecord(candidate) ||
201
229
  candidate.schema !== "humanish.feedback-candidate.v1" ||
@@ -245,6 +245,8 @@ export function missingKeyHint(name) {
245
245
  return `run \`${cli("keys set openai")}\``;
246
246
  case "ANTHROPIC_API_KEY":
247
247
  return `run \`${cli("keys set anthropic")}\``;
248
+ case "AGENTMAIL_API_KEY":
249
+ return `run \`${cli("keys set agentmail")}\``;
248
250
  default:
249
251
  return `run \`${cli(`keys set ${name}`)}\``;
250
252
  }
@@ -0,0 +1,37 @@
1
+ import { type KeyResolutionDeps, type KeySourceProbe } from "./key-resolution.js";
2
+ interface KeyStatusRow extends KeySourceProbe {
3
+ /** What humanish uses the key for, in a few words. */
4
+ use: string;
5
+ }
6
+ export declare function keyStatus(args: {
7
+ cwd: string;
8
+ env: NodeJS.ProcessEnv;
9
+ deps?: KeyResolutionDeps;
10
+ }): Promise<KeyStatusRow[]>;
11
+ /** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
12
+ export declare function otherStoredKeys(env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): string[];
13
+ export declare function formatKeyStatus(rows: readonly KeyStatusRow[], otherStored?: readonly string[]): string;
14
+ interface MissingKeysOutcome {
15
+ stored: string[];
16
+ /** Keys left unset because the person pressed Enter without a value. */
17
+ skipped: string[];
18
+ /** Keys whose value the store refused, with the reason. */
19
+ rejected?: Array<{
20
+ name: string;
21
+ message: string;
22
+ }>;
23
+ /** The key whose prompt was cancelled. No later key was asked for. */
24
+ stoppedAt?: string;
25
+ }
26
+ /**
27
+ * `humanish keys set` with no key named: ask for each missing key in turn and store each answer in
28
+ * the user store. An empty answer skips that key; a cancelled prompt (null) stops the walk and
29
+ * keeps what was already stored.
30
+ */
31
+ export declare function askForMissingKeys(args: {
32
+ rows: readonly KeyStatusRow[];
33
+ env: NodeJS.ProcessEnv;
34
+ deps?: KeyResolutionDeps;
35
+ prompt: (label: string) => Promise<string | null>;
36
+ }): Promise<MissingKeysOutcome>;
37
+ export {};
@@ -0,0 +1,68 @@
1
+ // `humanish keys`: which provider keys are set and where each comes from, through the same chain a
2
+ // live command resolves. Never a value.
3
+ import { cli } from "../cli/invocation.js";
4
+ import { listUserKeys, probeKeySources, setUserKey, } from "./key-resolution.js";
5
+ /**
6
+ * The keys a person sets for humanish, in the order the status lists them and `keys set` asks for
7
+ * them. ANTHROPIC_API_KEY is left out: a Claude Code participant runs on its own login and never
8
+ * receives it. CODEX_API_KEY is an alternative spelling of the OpenAI key for terminal studies.
9
+ */
10
+ const STATUS_KEYS = [
11
+ { name: "E2B_API_KEY", use: "hosted desktops" },
12
+ { name: "OPENAI_API_KEY", use: "participant model and analysis" },
13
+ { name: "GH_TOKEN", use: "private repository subjects" },
14
+ { name: "AGENTMAIL_API_KEY", use: "email in studies" },
15
+ ];
16
+ export async function keyStatus(args) {
17
+ const probes = await probeKeySources(STATUS_KEYS.map((key) => key.name), {
18
+ cwd: args.cwd,
19
+ env: args.env,
20
+ ...(args.deps === undefined ? {} : { deps: args.deps }),
21
+ });
22
+ return probes.map((probe, index) => ({ ...probe, use: STATUS_KEYS[index].use }));
23
+ }
24
+ /** Names in the user store that the status does not list, such as ANTHROPIC_API_KEY. */
25
+ export function otherStoredKeys(env, deps = {}) {
26
+ const listed = new Set(STATUS_KEYS.map((key) => key.name));
27
+ return listUserKeys(env, deps).filter((name) => !listed.has(name));
28
+ }
29
+ export function formatKeyStatus(rows, otherStored = []) {
30
+ const lines = rows.map((row) => `${row.name} (${row.use}): ${row.source === null ? `missing; ${row.hint}` : `set, from ${row.source}`}`);
31
+ if (otherStored.length > 0)
32
+ lines.push(`Also in the user store: ${otherStored.join(", ")}. humanish does not use ${otherStored.length === 1 ? "it" : "them"} today.`);
33
+ const missing = rows.some((row) => row.source === null);
34
+ lines.push("", missing
35
+ ? `Values are never printed. \`${cli("keys set")}\` asks for each missing key in turn.`
36
+ : "Values are never printed.");
37
+ return `${lines.join("\n")}\n`;
38
+ }
39
+ /**
40
+ * `humanish keys set` with no key named: ask for each missing key in turn and store each answer in
41
+ * the user store. An empty answer skips that key; a cancelled prompt (null) stops the walk and
42
+ * keeps what was already stored.
43
+ */
44
+ export async function askForMissingKeys(args) {
45
+ const outcome = { stored: [], skipped: [] };
46
+ for (const row of args.rows) {
47
+ if (row.source !== null)
48
+ continue;
49
+ const value = await args.prompt(`${row.name} for ${row.use}`);
50
+ if (value === null)
51
+ return { ...outcome, stoppedAt: row.name };
52
+ if (value === "") {
53
+ outcome.skipped.push(row.name);
54
+ continue;
55
+ }
56
+ try {
57
+ setUserKey(row.name, value, args.env, args.deps);
58
+ outcome.stored.push(row.name);
59
+ }
60
+ catch (error) {
61
+ (outcome.rejected ??= []).push({
62
+ name: row.name,
63
+ message: error instanceof Error ? error.message : String(error),
64
+ });
65
+ }
66
+ }
67
+ return outcome;
68
+ }
@@ -8,6 +8,7 @@ import { isLocalOnlyRunFile, isLocalOnlyRunFileIdentity } from "../run/local-onl
8
8
  import { listRuns } from "../run/stored-runs.js";
9
9
  import { readAnalysisAccounting } from "../run/costs.js";
10
10
  import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
11
+ import { isRunOutcome } from "../run/bundle-shape.js";
11
12
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
12
13
  import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
13
14
  import { renderObserverHtml } from "./artifact.js";
@@ -138,7 +139,7 @@ async function readObserverData(runRoot, runtimeStreamUrls = []) {
138
139
  if (!bundleBytes)
139
140
  throw new Error("run.json unavailable");
140
141
  const bundle = JSON.parse(bundleBytes.toString("utf8"));
141
- return withRuntimeStreamUrls(withLocalRunStatus(record, buildObserverData(bundle, undefined, record)), runtimeStreamUrls);
142
+ return withRuntimeStreamUrls(withLocalRunStatus(record, buildObserverData(bundle, undefined, record), bundle.outcome), runtimeStreamUrls);
142
143
  }
143
144
  catch { }
144
145
  try {
@@ -166,12 +167,12 @@ async function readLocalRunStatus(runRoot) {
166
167
  }
167
168
  }
168
169
  /**
169
- * Liveness is a current read of a contained local status record, separate from run evidence.
170
+ * A recorded bundle outcome wins; otherwise runtime status reads the contained local heartbeat.
170
171
  * A stale heartbeat means unknown: neither an old timestamp nor a persisted PID proves that a
171
172
  * process died (the evidence may have been copied from another machine). A record its stopped
172
173
  * process wrote is interrupted. No PID is served/probed.
173
174
  */
174
- function withLocalRunStatus(record, input) {
175
+ function withLocalRunStatus(record, input, outcome) {
175
176
  // A served observation must never be inherited from a persisted projection or export.
176
177
  const { runtime: _persistedRuntime, ...data } = input;
177
178
  if (record === undefined || record.runId !== data.run.runId || record.mode !== data.run.mode)
@@ -181,7 +182,10 @@ function withLocalRunStatus(record, input) {
181
182
  const updated = Date.parse(record.updatedAt);
182
183
  const timestampsValid = Number.isFinite(started) && Number.isFinite(updated) && started <= updated && updated <= now;
183
184
  let state = "unknown";
184
- if (timestampsValid) {
185
+ if (isRunOutcome(outcome)) {
186
+ state = outcome.state;
187
+ }
188
+ else if (timestampsValid) {
185
189
  if (record.state === "finished") {
186
190
  state = "finished";
187
191
  }