humanish 0.112.0 → 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 (90) hide show
  1. package/CHANGELOG.md +19 -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 +9 -4
  20. package/dist/actors/computer-use/openai-conversation.js +53 -24
  21. package/dist/actors/computer-use/openai-provider.js +18 -12
  22. package/dist/actors/contract.d.ts +43 -3
  23. package/dist/actors/contract.js +13 -0
  24. package/dist/analysis/evidence-sources.d.ts +3 -1
  25. package/dist/analysis/evidence-sources.js +10 -1
  26. package/dist/analysis/evidence.js +2 -2
  27. package/dist/analysis/execute.d.ts +1 -1
  28. package/dist/analysis/execute.js +3 -1
  29. package/dist/cli/commands/runs.js +33 -10
  30. package/dist/cli/commands/setup.js +95 -6
  31. package/dist/cli/commands/study-format.js +7 -4
  32. package/dist/cli/commands/study.js +1 -1
  33. package/dist/cli/commands/tui.js +1 -1
  34. package/dist/cli/doctor.d.ts +2 -0
  35. package/dist/cli/doctor.js +18 -0
  36. package/dist/cli/io.d.ts +2 -0
  37. package/dist/cli/program.js +35 -1
  38. package/dist/cli/secret-prompt.d.ts +5 -1
  39. package/dist/cli/secret-prompt.js +6 -2
  40. package/dist/cli/update-check-worker.d.ts +1 -0
  41. package/dist/cli/update-check-worker.js +5 -0
  42. package/dist/cli/update-check.d.ts +46 -0
  43. package/dist/cli/update-check.js +203 -0
  44. package/dist/feedback/draft.js +18 -0
  45. package/dist/keys/key-resolution.js +2 -0
  46. package/dist/keys/key-status.d.ts +37 -0
  47. package/dist/keys/key-status.js +68 -0
  48. package/dist/observer-app.html +4 -4
  49. package/dist/routes/computer-use/local-vm.js +9 -2
  50. package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
  51. package/dist/routes/computer-use/participant-execution.js +9 -1
  52. package/dist/routes/computer-use/rerun-selection.js +23 -21
  53. package/dist/routes/computer-use/result.js +3 -2
  54. package/dist/routes/computer-use/self-report.js +7 -1
  55. package/dist/routes/computer-use/setup.js +14 -0
  56. package/dist/routes/computer-use/types.d.ts +4 -1
  57. package/dist/routes/shared-world/bundle-records.js +8 -7
  58. package/dist/routes/shared-world/bundle.js +1 -1
  59. package/dist/run/detail.js +4 -4
  60. package/dist/run/participant-caption.d.ts +2 -1
  61. package/dist/run/participant-caption.js +11 -1
  62. package/dist/run/participant-records.d.ts +3 -1
  63. package/dist/run/participant-records.js +6 -0
  64. package/dist/run-study.js +17 -1
  65. package/dist/study/discover.js +6 -1
  66. package/dist/study/doctor.d.ts +2 -0
  67. package/dist/study/doctor.js +45 -7
  68. package/dist/study/init-templates.js +10 -5
  69. package/dist/study/plan.js +3 -2
  70. package/dist/study/preflight-result.d.ts +1 -0
  71. package/dist/study/preflight-result.js +1 -1
  72. package/dist/study/preflight.d.ts +2 -0
  73. package/dist/study/preflight.js +54 -14
  74. package/dist/study/warnings.d.ts +5 -0
  75. package/dist/study/warnings.js +79 -2
  76. package/dist/substrates/local/capacity.d.ts +75 -0
  77. package/dist/substrates/local/capacity.js +143 -0
  78. package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
  79. package/dist/substrates/local/firecracker-desktop.js +34 -3
  80. package/dist/substrates/local/runtime-host.d.ts +16 -3
  81. package/dist/substrates/local/runtime-host.js +49 -4
  82. package/dist/substrates/local/runtime.d.ts +10 -0
  83. package/dist/substrates/local/runtime.js +44 -5
  84. package/dist/tui-app.js +58 -58
  85. package/docs/architecture/actor-contract.md +20 -6
  86. package/docs/contracts/feedback.md +8 -1
  87. package/docs/contracts/schemas.md +57 -34
  88. package/docs/contracts/study-analysis.md +15 -0
  89. package/package.json +1 -1
  90. package/skills/humanish/SKILL.md +21 -10
@@ -12,6 +12,8 @@ export interface DoctorResult {
12
12
  schema: typeof DOCTOR_SCHEMA;
13
13
  ok: boolean;
14
14
  cwd: string;
15
+ /** The step to take first, such as preparing a local study's runtime. */
16
+ next?: string;
15
17
  checks: Array<{
16
18
  name: string;
17
19
  ok: boolean;
@@ -11,6 +11,8 @@ import { studySetupChecks, studiesByRequiredKey, } from "../study/doctor.js";
11
11
  import { prepareSelectedOutputDirectory, } from "../run/contained-output.js";
12
12
  import { implicitProjectDirectoryExists, readImplicitProjectFile, validateCwd, } from "../run/project.js";
13
13
  import { cli } from "./invocation.js";
14
+ import { recordedVersion } from "./update-check.js";
15
+ import { CLI_VERSION } from "./version.js";
14
16
  const DOCTOR_SCHEMA = "humanish.doctor-result.v1";
15
17
  /**
16
18
  * Version 2.3.1 stopped holding a launched background command's event stream open. Version 2.3.2
@@ -94,6 +96,7 @@ export async function doctor(cwdInput, options = {}) {
94
96
  ...(await projectChecks(projectRoot)),
95
97
  await desktopSdkCheck(setup),
96
98
  terminalSurfaceCheck(),
99
+ ...versionCheck(env),
97
100
  ...localAgentChecks(agents),
98
101
  await claudeTranscriptCheck(env),
99
102
  ...keyChecks(probes, receivingKey, setup, keyUsers),
@@ -109,6 +112,7 @@ export async function doctor(cwdInput, options = {}) {
109
112
  schema: DOCTOR_SCHEMA,
110
113
  ok: checks.every((check) => check.ok),
111
114
  cwd,
115
+ ...(setup?.next === undefined ? {} : { next: setup.next }),
112
116
  checks: withStatus(checks),
113
117
  };
114
118
  }
@@ -246,6 +250,20 @@ function terminalSurfaceCheck() {
246
250
  }),
247
251
  };
248
252
  }
253
+ /** The latest version the update check recorded on this machine; no row before a check. */
254
+ function versionCheck(env) {
255
+ const recorded = recordedVersion(CLI_VERSION, env, env.HOME ?? homedir());
256
+ if (recorded === undefined)
257
+ return [];
258
+ return [
259
+ {
260
+ name: "humanish version",
261
+ ok: true,
262
+ ...(recorded.newer ? { status: "note" } : {}),
263
+ message: recorded.message,
264
+ },
265
+ ];
266
+ }
249
267
  /** Probes the live-run keys, plus the receiving-email key a real-comms study names. */
250
268
  async function probeDoctorKeys(cwd, env, study, keyDeps, dotenv) {
251
269
  const keyNames = new Set(["OPENAI_API_KEY", "E2B_API_KEY", "GH_TOKEN", "CODEX_API_KEY"]);
package/dist/cli/io.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command, Option } from "commander";
2
2
  import { discoverProviderKeys, type DotenvLoad } from "../keys/key-resolution.js";
3
3
  import { type TelemetryProperties } from "./telemetry.js";
4
+ import type { UpdateCheckContext } from "./update-check.js";
4
5
  import type { RunResult } from "../run/results.js";
5
6
  export declare const CLI_RESPONSE_SCHEMA = "humanish.cli-response.v1";
6
7
  export { CLI_VERSION } from "./version.js";
@@ -9,6 +10,7 @@ export interface CliIo {
9
10
  writeErr(text: string): void;
10
11
  setExitCode(code: number): void;
11
12
  keyDiscovery?: typeof discoverProviderKeys;
13
+ updateCheck?: Partial<UpdateCheckContext>;
12
14
  }
13
15
  export declare const JSON_OPTION_DESCRIPTION = "Print a machine-readable JSON response.";
14
16
  export declare const CWD_OPTION_DESCRIPTION = "Project directory.";
@@ -2,10 +2,11 @@ import { readFileSync } from "node:fs";
2
2
  import { formatOrientationHuman, readOrientation } from "./orientation.js";
3
3
  import { dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { Command } from "commander";
5
+ import { Command, Help } from "commander";
6
6
  import { redactText } from "../evidence/redaction.js";
7
7
  import { PortInUseError } from "../observer/listen.js";
8
8
  import { buildPayload, disabledByEnvironment, durationBucket, readTelemetryState, sendTelemetry, TELEMETRY_NOTICE, writeTelemetryState, isOwnCheckoutRun, } from "./telemetry.js";
9
+ import { checkForUpdate, startRefreshWorker } from "./update-check.js";
9
10
  import { registerAnalyzeCommand } from "./commands/analyze.js";
10
11
  import { registerCodexCommands } from "./commands/codex.js";
11
12
  import { registerCommsCommands } from "./commands/comms.js";
@@ -95,6 +96,29 @@ async function recordCommandTelemetry(command, ok, durationMs, exitCode) {
95
96
  // Metrics are our problem, never the operator's.
96
97
  }
97
98
  }
99
+ /**
100
+ * After a command, one line on stderr when a newer humanish is published. It reads only the cache;
101
+ * the registry request runs detached (see update-check.ts), so nothing here waits on the network.
102
+ */
103
+ function noticeNewerVersion(command, io) {
104
+ try {
105
+ const notice = checkForUpdate({
106
+ installed: CLI_VERSION,
107
+ env: process.env,
108
+ terminal: process.stderr.isTTY === true,
109
+ json: wantsJson(command),
110
+ ownCheckout: isOwnCheckoutRun(process.cwd(), dirname(fileURLToPath(import.meta.url)), (p) => readFileSync(p, "utf8")),
111
+ now: Date.now(),
112
+ startRefresh: startRefreshWorker,
113
+ ...io.updateCheck,
114
+ });
115
+ if (notice !== undefined)
116
+ io.writeErr(`${notice}\n`);
117
+ }
118
+ catch {
119
+ // An update notice is never a reason for a command to fail.
120
+ }
121
+ }
98
122
  /**
99
123
  * HUMANISH_DEBUG_HANDLES=1: after a command's handler settles, name what is still keeping the
100
124
  * process alive. A live terminal run wrote its result 64 s in and the CLI stayed up for
@@ -165,6 +189,7 @@ class HumanishCommand extends Command {
165
189
  const noticed = announceTelemetryOnce(this, cliIo);
166
190
  const finish = (ok) => {
167
191
  void recordCommandTelemetry(this, ok, Date.now() - startedAt, lastExitCode);
192
+ noticeNewerVersion(this, cliIo);
168
193
  reportActiveHandles(this, cliIo);
169
194
  };
170
195
  try {
@@ -326,6 +351,15 @@ export function createProgram(io = {}) {
326
351
  // each subcommand when it is created.
327
352
  .helpOption("-h, --help", "Show help for this command.")
328
353
  .helpCommand("help [command]", "Show help for a command.")
354
+ // `help <command>` and `<command> help` keep working; a command's listing names only the
355
+ // commands that do something, so `help` is left out of every Commands section.
356
+ .configureHelp({
357
+ visibleCommands(command) {
358
+ return Help.prototype.visibleCommands
359
+ .call(this, command)
360
+ .filter((child) => child.name() !== "help");
361
+ },
362
+ })
329
363
  .showHelpAfterError()
330
364
  .option("--json", "Print machine-readable JSON responses where supported.")
331
365
  .configureOutput({
@@ -1,2 +1,6 @@
1
- /** Host-owned entry: no secret crosses the TUI view contract or reaches a writable terminal. */
1
+ /**
2
+ * Host-owned entry: no secret crosses the TUI view contract or reaches a writable terminal.
3
+ * Resolves to the trimmed line, an empty string when Enter was pressed alone, or null when the
4
+ * person cancelled or there is no terminal.
5
+ */
2
6
  export declare function promptSecret(label: string, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream): Promise<string | null>;
@@ -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
+ }
@@ -161,6 +161,7 @@ export async function buildAnalysisDraft(context, options) {
161
161
  `Impact: ${finding.impact}. ${finding.affectedStreamIds.length} affected / ${finding.exposedStreamIds.length} observed exposed participants. ${finding.exposureReason}`,
162
162
  `Recovery: ${finding.recovery}. Confidence: ${finding.confidence}.`,
163
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}`),
164
165
  `Next check: ${finding.nextStep}`,
165
166
  correction
166
167
  ? `Human review: ${correction.status}. ${correction.reason}`
@@ -206,6 +207,23 @@ export async function buildAnalysisDraft(context, options) {
206
207
  ],
207
208
  };
208
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
+ }
209
227
  export function isUsableFeedbackCandidate(candidate) {
210
228
  if (!isRecord(candidate) ||
211
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
+ }