humanish 0.112.0 → 0.114.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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +12 -1
  4. package/TELEMETRY.md +21 -0
  5. package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
  6. package/dist/actors/codex/restricted-participant-policy.js +20 -2
  7. package/dist/actors/codex/restricted-participant.d.ts +1 -1
  8. package/dist/actors/codex/restricted-participant.js +2 -1
  9. package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
  10. package/dist/actors/computer-use/loop/debrief.js +57 -20
  11. package/dist/actors/computer-use/loop/ending.d.ts +5 -2
  12. package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
  13. package/dist/actors/computer-use/loop/impressions.js +40 -0
  14. package/dist/actors/computer-use/loop/session.d.ts +3 -1
  15. package/dist/actors/computer-use/loop/session.js +2 -0
  16. package/dist/actors/computer-use/loop/trace.js +2 -0
  17. package/dist/actors/computer-use/loop/types.d.ts +8 -1
  18. package/dist/actors/computer-use/loop.d.ts +2 -1
  19. package/dist/actors/computer-use/loop.js +15 -3
  20. package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
  21. package/dist/actors/computer-use/openai-conversation.js +60 -29
  22. package/dist/actors/computer-use/openai-provider.js +18 -12
  23. package/dist/actors/contract.d.ts +47 -5
  24. package/dist/actors/contract.js +13 -0
  25. package/dist/analysis/admission.d.ts +37 -0
  26. package/dist/analysis/admission.js +60 -0
  27. package/dist/analysis/automatic-completion.d.ts +0 -6
  28. package/dist/analysis/automatic-completion.js +3 -3
  29. package/dist/analysis/automatic-config.d.ts +10 -2
  30. package/dist/analysis/automatic-config.js +29 -14
  31. package/dist/analysis/automatic.js +7 -0
  32. package/dist/analysis/evidence-sources.d.ts +3 -1
  33. package/dist/analysis/evidence-sources.js +10 -1
  34. package/dist/analysis/evidence.js +2 -2
  35. package/dist/analysis/execute.d.ts +20 -6
  36. package/dist/analysis/execute.js +61 -28
  37. package/dist/analysis/image-tokens.d.ts +2 -0
  38. package/dist/analysis/image-tokens.js +1 -1
  39. package/dist/analysis/job.d.ts +9 -1
  40. package/dist/analysis/job.js +11 -2
  41. package/dist/analysis/service.js +6 -3
  42. package/dist/cli/commands/analyze.js +7 -2
  43. package/dist/cli/commands/notes.d.ts +3 -0
  44. package/dist/cli/commands/notes.js +144 -0
  45. package/dist/cli/commands/observe.js +1 -1
  46. package/dist/cli/commands/runs.js +33 -10
  47. package/dist/cli/commands/setup.js +95 -6
  48. package/dist/cli/commands/study-format.js +7 -4
  49. package/dist/cli/commands/study-run.js +2 -1
  50. package/dist/cli/commands/study.js +1 -1
  51. package/dist/cli/commands/tui.js +1 -1
  52. package/dist/cli/doctor.d.ts +2 -0
  53. package/dist/cli/doctor.js +18 -0
  54. package/dist/cli/findings.js +4 -1
  55. package/dist/cli/io.d.ts +3 -0
  56. package/dist/cli/io.js +11 -12
  57. package/dist/cli/program.js +37 -1
  58. package/dist/cli/secret-prompt.d.ts +5 -1
  59. package/dist/cli/secret-prompt.js +6 -2
  60. package/dist/cli/update-check-worker.d.ts +1 -0
  61. package/dist/cli/update-check-worker.js +5 -0
  62. package/dist/cli/update-check.d.ts +46 -0
  63. package/dist/cli/update-check.js +203 -0
  64. package/dist/evidence/encoded-text.d.ts +15 -2
  65. package/dist/evidence/encoded-text.js +68 -4
  66. package/dist/evidence/secret-scrub.d.ts +1 -1
  67. package/dist/evidence/secret-scrub.js +23 -2
  68. package/dist/feedback/draft.d.ts +19 -0
  69. package/dist/feedback/draft.js +69 -1
  70. package/dist/feedback/export-bundle.js +2 -2
  71. package/dist/feedback/export.js +47 -3
  72. package/dist/feedback/feedback.js +42 -2
  73. package/dist/keys/key-resolution.js +2 -0
  74. package/dist/keys/key-status.d.ts +37 -0
  75. package/dist/keys/key-status.js +68 -0
  76. package/dist/observer/artifact.d.ts +5 -0
  77. package/dist/observer/artifact.js +18 -2
  78. package/dist/observer/notes-route.d.ts +21 -0
  79. package/dist/observer/notes-route.js +140 -0
  80. package/dist/observer/pinned-files.d.ts +8 -1
  81. package/dist/observer/pinned-files.js +32 -3
  82. package/dist/observer/render.js +38 -14
  83. package/dist/observer/run-routes.d.ts +8 -2
  84. package/dist/observer/run-routes.js +47 -6
  85. package/dist/observer/serve.d.ts +3 -0
  86. package/dist/observer/serve.js +28 -3
  87. package/dist/observer-app.html +7 -6
  88. package/dist/routes/computer-use/local-vm.js +9 -2
  89. package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
  90. package/dist/routes/computer-use/participant-execution.js +9 -1
  91. package/dist/routes/computer-use/rerun-selection.js +23 -21
  92. package/dist/routes/computer-use/result.js +3 -2
  93. package/dist/routes/computer-use/self-report.js +7 -1
  94. package/dist/routes/computer-use/setup.js +14 -0
  95. package/dist/routes/computer-use/types.d.ts +4 -1
  96. package/dist/routes/shared-world/bundle-records.js +8 -7
  97. package/dist/routes/shared-world/bundle.js +1 -1
  98. package/dist/run/contained-output.d.ts +7 -0
  99. package/dist/run/contained-output.js +35 -1
  100. package/dist/run/detail.js +4 -4
  101. package/dist/run/note-files.d.ts +73 -0
  102. package/dist/run/note-files.js +184 -0
  103. package/dist/run/notes.d.ts +37 -0
  104. package/dist/run/notes.js +198 -0
  105. package/dist/run/participant-caption.d.ts +11 -1
  106. package/dist/run/participant-caption.js +30 -1
  107. package/dist/run/participant-records.d.ts +3 -1
  108. package/dist/run/participant-records.js +6 -0
  109. package/dist/run-study.js +17 -1
  110. package/dist/study/discover.js +6 -1
  111. package/dist/study/doctor.d.ts +2 -0
  112. package/dist/study/doctor.js +46 -8
  113. package/dist/study/init-templates.js +10 -5
  114. package/dist/study/plan.js +3 -2
  115. package/dist/study/preflight-result.d.ts +1 -0
  116. package/dist/study/preflight-result.js +3 -2
  117. package/dist/study/preflight.d.ts +2 -0
  118. package/dist/study/preflight.js +54 -14
  119. package/dist/study/summary.js +2 -2
  120. package/dist/study/warnings.d.ts +5 -0
  121. package/dist/study/warnings.js +79 -2
  122. package/dist/substrates/local/capacity.d.ts +75 -0
  123. package/dist/substrates/local/capacity.js +143 -0
  124. package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
  125. package/dist/substrates/local/firecracker-desktop.js +34 -3
  126. package/dist/substrates/local/runtime-host.d.ts +16 -3
  127. package/dist/substrates/local/runtime-host.js +49 -4
  128. package/dist/substrates/local/runtime.d.ts +10 -0
  129. package/dist/substrates/local/runtime.js +44 -5
  130. package/dist/tui-app.js +58 -58
  131. package/docs/architecture/actor-contract.md +20 -6
  132. package/docs/contracts/errors.md +2 -2
  133. package/docs/contracts/feedback.md +8 -1
  134. package/docs/contracts/run-bundle.md +57 -0
  135. package/docs/contracts/schemas.md +62 -36
  136. package/docs/contracts/study-analysis.md +41 -8
  137. package/docs/principles/invariants-and-defaults.md +2 -2
  138. package/docs/product/automatic-analysis.md +22 -16
  139. package/package.json +2 -2
  140. package/skills/humanish/SKILL.md +26 -10
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { Command, Option } from "commander";
2
+ import { Command, InvalidArgumentError, Option } from "commander";
3
3
  import { shellArg } from "../../substrates/shell.js";
4
4
  import { computeStats, formatStatsHuman } from "../../run/stats.js";
5
5
  import { DEFAULT_EXPORT_MAX_BYTES, exportRun, formatExportHuman } from "../../feedback/export.js";
@@ -8,7 +8,7 @@ import { verifyRun } from "../../verify/verify.js";
8
8
  import { reclaimPreflightSandboxes, reclaimRunSandboxes, } from "../../run/reclaim.js";
9
9
  import { runDisplay } from "../../run/display.js";
10
10
  import { addRunOptions, handleRun } from "./run-command.js";
11
- import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
11
+ import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, parsePositiveInteger, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
12
12
  import { plural } from "../../run/text.js";
13
13
  import { warnAndQueue } from "../deprecations.js";
14
14
  import { formatFindings, readRunFindings } from "../findings.js";
@@ -136,29 +136,52 @@ export function registerRunsCommand(parent, io) {
136
136
  io.setExitCode(result.ok ? 0 : 2);
137
137
  });
138
138
  }
139
+ /** A whole number of GiB or CPUs above zero, for `runtime setup --memory` and `--cpus`. */
140
+ function wholeNumber(flag) {
141
+ return (value) => {
142
+ const parsed = parsePositiveInteger(value);
143
+ if (parsed === null)
144
+ throw new InvalidArgumentError(`${flag} takes a whole number above zero, such as 8.`);
145
+ return parsed;
146
+ };
147
+ }
139
148
  export function registerRuntimeCommands(parent, io) {
140
149
  const runtime = parent
141
150
  .command("runtime")
142
151
  .description("Prepare or inspect the local browser runtime.");
143
152
  for (const action of ["status", "setup"]) {
144
- runtime
153
+ const command = runtime
145
154
  .command(action)
146
155
  .description(action === "status"
147
- ? "Check local Docker, virtualization and the cached browser image without downloads."
156
+ ? "Check local Docker, virtualization and the cached browser image without downloads, and how many participant desktops fit."
148
157
  : "Download and install the local browser image. Does not start a study or use model quota.")
149
158
  .option("--json", JSON_OPTION_DESCRIPTION)
150
- .option("--media", "Prepare or inspect the optional camera and speech runtime.")
151
- .action(async (_options, command) => {
159
+ .option("--media", "Prepare or inspect the optional camera and speech runtime.");
160
+ if (action === "setup")
161
+ command
162
+ .option("--memory <GiB>", "Mac only: the humanish Lima VM's memory. Resizes an existing VM, which stops it first.", wholeNumber("--memory"))
163
+ .option("--cpus <n>", "Mac only: the humanish Lima VM's CPUs. Resizes an existing VM, which stops it first.", wholeNumber("--cpus"));
164
+ command.action(async (options, command) => {
152
165
  const { localRuntimeStatus, prepareLocalRuntime } = await import("../../substrates/local/runtime.js");
166
+ const { describeCapacity } = await import("../../substrates/local/capacity.js");
167
+ const media = options.media === true;
153
168
  try {
154
- if (action === "setup")
169
+ if (action === "setup") {
170
+ const size = {
171
+ ...(options.memory === undefined ? {} : { memoryGiB: options.memory }),
172
+ ...(options.cpus === undefined ? {} : { cpus: options.cpus }),
173
+ };
155
174
  await prepareLocalRuntime({
156
- media: _options.media === true,
175
+ media,
176
+ ...(Object.keys(size).length === 0 ? {} : { size }),
157
177
  progress: (message) => io.writeErr(`${message}\n`),
158
178
  });
159
- const status = await localRuntimeStatus({ media: _options.media === true });
179
+ }
180
+ const status = await localRuntimeStatus({ media });
160
181
  const result = { schema: "humanish.runtime-result.v1", ...status };
161
- writeResult(command, io, result, () => `${status.message}\n`);
182
+ writeResult(command, io, result, () => [status.message, ...(status.capacity ? [describeCapacity(status.capacity)] : [])]
183
+ .map((line) => `${line}\n`)
184
+ .join(""));
162
185
  io.setExitCode(status.ok ? 0 : 2);
163
186
  }
164
187
  catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { listUserKeys, resolveKeyName, setUserKey, unsetUserKey, userKeyStorePath, } from "../../keys/key-resolution.js";
3
+ import { askForMissingKeys, formatKeyStatus, keyStatus, otherStoredKeys, } from "../../keys/key-status.js";
3
4
  import { promptSecret } from "../secret-prompt.js";
4
5
  import { runInit } from "../../study/init.js";
5
6
  import { buildPayload, disabledByEnvironment, readTelemetryState, telemetryStatePath, writeTelemetryState, } from "../telemetry.js";
@@ -177,20 +178,107 @@ async function readSecretValue(useStdin, promptLabel) {
177
178
  const text = Buffer.concat(chunks).toString("utf8").trim();
178
179
  return text.length > 0 ? text : null;
179
180
  }
180
- return promptSecret(promptLabel, process.stdin, process.stderr);
181
+ // An empty line is no value here: only the walk over missing keys reads it as "skip".
182
+ return (await promptSecret(promptLabel, process.stdin, process.stderr)) || null;
183
+ }
184
+ /** `humanish keys` and `humanish keys status`: each provider key's source, never a value. */
185
+ async function writeKeyStatus(command, io) {
186
+ const rows = await keyStatus({ cwd: process.cwd(), env: process.env });
187
+ const others = otherStoredKeys(process.env);
188
+ const missing = rows.filter((row) => row.source === null).length;
189
+ const result = {
190
+ schema: KEYS_RESULT_SCHEMA,
191
+ ok: true,
192
+ action: "status",
193
+ store: userKeyStorePath(process.env),
194
+ names: rows.filter((row) => row.source !== null).map((row) => row.name),
195
+ keys: rows.map(({ name, use, source, hint }) => ({
196
+ name,
197
+ use,
198
+ source,
199
+ ...(source === null ? { hint } : {}),
200
+ })),
201
+ ...(others.length > 0 ? { otherStored: others } : {}),
202
+ message: missing === 0
203
+ ? "Every provider key is set."
204
+ : `${plural(missing, "provider key")} ${missing === 1 ? "is" : "are"} missing.`,
205
+ };
206
+ writeResult(command, io, result, () => formatKeyStatus(rows, others));
207
+ io.setExitCode(0);
208
+ }
209
+ /** "A", "A and B", "A, B and C". */
210
+ function listNames(names) {
211
+ return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
212
+ }
213
+ /**
214
+ * `humanish keys set` with no key named. On a terminal it asks for each missing provider key in
215
+ * turn; without one there is no one to ask, so it refuses and names the --stdin form.
216
+ */
217
+ async function setMissingKeys(command, io, useStdin) {
218
+ const store = userKeyStorePath(process.env);
219
+ const finish = (ok, names, message) => {
220
+ const result = {
221
+ schema: KEYS_RESULT_SCHEMA,
222
+ ok,
223
+ action: "set",
224
+ store,
225
+ names,
226
+ message,
227
+ };
228
+ writeResult(command, io, result, formatKeysHuman);
229
+ io.setExitCode(ok ? 0 : 2);
230
+ };
231
+ if (useStdin || process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
232
+ finish(false, [], `missing required argument 'vendor-or-name'. Without a terminal humanish cannot ask for each key, so name the key and pipe its value, for example: printf '%s' "$E2B_API_KEY" | ${cli("keys set e2b --stdin")}`);
233
+ return;
234
+ }
235
+ const rows = await keyStatus({ cwd: process.cwd(), env: process.env });
236
+ const missing = rows.filter((row) => row.source === null).length;
237
+ if (missing === 0) {
238
+ finish(true, [], `Every provider key is already set, so there is nothing to ask for. Replace one with \`${cli("keys set <vendor>")}\`.`);
239
+ return;
240
+ }
241
+ io.writeErr(`${plural(missing, "provider key")} ${missing === 1 ? "is" : "are"} missing. Paste each one when asked, or press Enter to skip it.\n`);
242
+ const outcome = await askForMissingKeys({
243
+ rows,
244
+ env: process.env,
245
+ prompt: (label) => promptSecret(label, process.stdin, process.stderr),
246
+ });
247
+ const sentences = [
248
+ outcome.stored.length > 0
249
+ ? `Stored ${listNames(outcome.stored)} (0600).`
250
+ : "Nothing was stored.",
251
+ ...(outcome.skipped.length > 0 ? [`Skipped ${listNames(outcome.skipped)}.`] : []),
252
+ ...(outcome.rejected ?? []).map((entry) => `${entry.name} was not stored: ${entry.message}`),
253
+ ...(outcome.stoppedAt === undefined
254
+ ? []
255
+ : [`Stopped at ${outcome.stoppedAt}; the keys after it were not asked for.`]),
256
+ `\`${cli("keys")}\` shows where each key comes from.`,
257
+ ];
258
+ // A cancel is the person's choice, so only a value the store refused fails the command.
259
+ finish(outcome.rejected === undefined, outcome.stored, sentences.join(" "));
181
260
  }
182
261
  export function registerKeysCommand(parent, io) {
183
262
  const keys = parent
184
263
  .command("keys")
185
- .description("Manage the humanish user-level key store used by provider-key discovery.")
186
- .summary("Store and manage your provider keys.");
264
+ .description("Show which provider keys are set and where each comes from, and manage the humanish user key store that provider-key discovery reads.")
265
+ .summary("Check and store your provider keys.");
266
+ keys
267
+ .command("status", { isDefault: true })
268
+ .description("Show each provider key humanish uses: where it comes from, or the command that adds it. Values are never printed.")
269
+ .option("--json", JSON_OPTION_DESCRIPTION)
270
+ .action(async (_options, command) => writeKeyStatus(command, io));
187
271
  keys
188
272
  .command("set")
189
- .argument("<vendor-or-name>", "A vendor alias (openai, e2b, anthropic, github, agentmail) or a raw ENV_NAME.")
190
- .description("Store one provider key in the user store (0600), prompted with hidden input.")
273
+ .argument("[vendor-or-name]", "A vendor alias (openai, e2b, anthropic, github, agentmail) or a raw ENV_NAME. Without one, a terminal asks for each missing key in turn.")
274
+ .description("Store one provider key in the user store (0600), prompted with hidden input. With no key named, ask for each missing key in turn.")
191
275
  .option("--stdin", "Read the value from stdin instead of prompting (for agents/pipes).")
192
276
  .option("--json", JSON_OPTION_DESCRIPTION)
193
277
  .action(async (vendorOrName, options, command) => {
278
+ if (vendorOrName === undefined) {
279
+ await setMissingKeys(command, io, options.stdin === true);
280
+ return;
281
+ }
194
282
  const name = resolveKeyName(vendorOrName);
195
283
  const storePath = userKeyStorePath(process.env);
196
284
  if (name === null) {
@@ -294,11 +382,12 @@ export function registerKeysCommand(parent, io) {
294
382
  function formatDoctorHuman(result) {
295
383
  return ([
296
384
  `humanish doctor ${result.ok ? "ok" : "needs setup"}`,
385
+ ...(result.next === undefined ? [] : [`next: ${result.next}`]),
297
386
  `cwd: ${result.cwd}`,
298
387
  // "missing" is a verdict, and a row that never ran has none. A participant reading doctor on a
299
388
  // fresh desktop got `- missing package.json: package.json is present and safe to read`, which
300
389
  // contradicts itself in eleven words (labs/tui-self-study.yaml).
301
- ...result.checks.map((check) => `- ${check.status.replace("_", " ")} ${check.name}: ${check.message}`),
390
+ ...result.checks.map((check) => `- ${check.status.replace("_", " ")} ${check.name}: ${check.message.replaceAll("\n", "\n ")}`),
302
391
  ].join("\n") + "\n");
303
392
  }
304
393
  function formatInitHuman(result) {
@@ -1,3 +1,4 @@
1
+ import { participantCaption } from "../../run/participant-caption.js";
1
2
  import { formatCuaDiagnostics, formatCuaStopCause } from "../../routes/computer-use/diagnostics.js";
2
3
  /** A run's first lines: the command that ran, whether it was a dry run, how it ended, and its route. */
3
4
  function runHeader(result, route) {
@@ -39,17 +40,19 @@ export function formatConcurrentSharedWorldStudyHuman(result) {
39
40
  ...runHeader(result, "shared-world"),
40
41
  `run: ${result.runId}`,
41
42
  `actor: ${result.actor}`,
42
- `topology: ${result.topology}/${result.topologyMode} (${result.roleCount} persona${result.roleCount === 1 ? "" : "s"}, concurrency ${result.concurrency})`,
43
+ `participants: ${result.roleCount}, up to ${result.concurrency} at once`,
43
44
  ...(result.host ? [`host: ${result.host}`] : []),
44
45
  ...(result.subject?.commit
45
- ? [`plane: ${result.subject.repo}@${result.subject.commit.slice(0, 12)}`]
46
+ ? [`app: ${result.subject.repo}@${result.subject.commit.slice(0, 12)}`]
46
47
  : []),
47
48
  ...(result.overlapProven === undefined
48
49
  ? []
49
50
  : [
50
- `overlap: ${result.overlapProven ? "proven" : "not observed"} (this run only; no scale or adoption claim)`,
51
+ result.overlapProven
52
+ ? "Participants used the app at the same time (observed in this run only)."
53
+ : "Participants were not observed using the app at the same time.",
51
54
  ]),
52
- ...result.roles.map((participant) => `persona ${participant.id} (${participant.persona}): ${participantStatus(participant.status)}${participant.session ? ` (${participant.session.completionReason})` : ""}${participant.ok ? "" : ", not ok"}`),
55
+ ...result.roles.map((participant) => `${participantCaption({ id: participant.id, personaId: participant.persona })}: ${participantStatus(participant.status)}${participant.ok ? "" : ", not ok"}`),
53
56
  ...(result.subjectSandbox
54
57
  ? [
55
58
  `subject sandbox: ${sandboxName(result.subjectSandbox)} killed=${result.subjectSandbox.killed ? "yes" : "no"}`,
@@ -7,6 +7,7 @@ import { warnAndQueue } from "../deprecations.js";
7
7
  import { planStudy, resolveStudyDryRun } from "../../study/plan.js";
8
8
  import { keyNamesOf } from "../../study/requirements.js";
9
9
  import { routeOf } from "../../study/plan.js";
10
+ import { computerUseParticipantCount } from "../../study/routing.js";
10
11
  import { computerUseRouteRun } from "./study-route-computer-use.js";
11
12
  import { runRoute } from "./study-route-run.js";
12
13
  import { scriptedRouteRun } from "./study-route-scripted.js";
@@ -130,7 +131,7 @@ export async function runStudyCommand(args) {
130
131
  // Surface it so the invoker (who may not be the manifest author) knows executable code ran.
131
132
  args.io.writeErr(`warning: review scorer ${scorer.provenance.ref} (${scorer.provenance.source}) is code that humanish loaded and ran in this process. Review it as you would any code you run.\n`);
132
133
  }
133
- const analysisBudget = automaticAnalysisBudget(config.review?.analysis, route);
134
+ const analysisBudget = automaticAnalysisBudget(config.review?.analysis, route, computerUseParticipantCount(config));
134
135
  if (analysisBudget && resolveStudyDryRun(config, args.options.dryRun, true) === false) {
135
136
  args.io.writeErr(`${formatAutomaticAnalysisBudget(analysisBudget)}\n`);
136
137
  }
@@ -140,7 +140,7 @@ function formatStudyCheckHuman(result) {
140
140
  ...(result.sandbox.created
141
141
  ? [`sandbox: created=yes killed=${result.sandbox.killed === true ? "yes" : "no"}`]
142
142
  : []),
143
- ...result.checks.map((check) => `- ${check.ok ? "ok" : "fail"} ${check.name}: ${check.message}`),
143
+ ...result.checks.map((check) => `- ${check.checked === false ? "not checked" : check.ok ? "ok" : "fail"} ${check.name}: ${check.message}`),
144
144
  ...result.warnings.map((warning) => `warning: ${warning}`),
145
145
  ].join("\n") + "\n";
146
146
  return result.error === undefined ? stdout : { stdout, error: result.error };
@@ -278,7 +278,7 @@ async function storeAgentmailKey(session) {
278
278
  const { cwd, runtime, sessionEnv } = session;
279
279
  // startTui has unmounted: only the host reads the credential, then remounts the view.
280
280
  const value = await runtime.promptSecret("AgentMail API key", runtime.stdin, runtime.stdout);
281
- if (value === null)
281
+ if (value === null || value === "")
282
282
  return "Key entry cancelled. Nothing was changed.";
283
283
  try {
284
284
  setUserKey("AGENTMAIL_API_KEY", value, sessionEnv);
@@ -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"]);
@@ -2,6 +2,7 @@
2
2
  // `humanish.analysis-findings.v1` projection and its text. The analysis artifact stays the record;
3
3
  // this reads the version `analyze show` selects and never starts an analysis.
4
4
  import path from "node:path";
5
+ import { admittingMaxCost, costRefusalText } from "../analysis/admission.js";
5
6
  import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
6
7
  import { loadAnalysis } from "../analysis/load.js";
7
8
  import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
@@ -199,9 +200,11 @@ export function analysisFindings(source) {
199
200
  const outcome = analysisOutcomeText(job);
200
201
  return withoutFindings(source, "failed", job.reason, `The automatic analysis ${outcome.startsWith("failed") ? "" : "was "}${outcome}.`, analyzeCommand(source, job.reason === "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE" ? "codex" : undefined));
201
202
  }
203
+ if (job?.state === "skipped" && job.admission)
204
+ return withoutFindings(source, "skipped", job.reason, `The automatic analysis was refused before it started. ${costRefusalText(job.admission)}`, cli(`analyze --run ${source.runId}${source.cwdFlag} --max-cost ${admittingMaxCost(job.admission)}`));
202
205
  if (job?.state === "skipped") {
203
206
  const refused = job.reason === "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED";
204
- return withoutFindings(source, "skipped", job.reason, `The automatic analysis was ${analysisOutcomeText(job)}.${refused ? " The dry run prints its cost estimate; run it again with a --max-cost above the estimate." : ""}`, job.reason === "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE"
207
+ return withoutFindings(source, "skipped", job.reason, `The automatic analysis was ${analysisOutcomeText(job)}.${refused ? " The dry run says why, and for a cost refusal names the --max-cost that admits it." : ""}`, job.reason === "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE"
205
208
  ? null
206
209
  : `${analyzeCommand(source, undefined)}${refused ? " --dry-run" : ""}`);
207
210
  }
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.";
@@ -136,4 +138,5 @@ export declare function analysisOutcomeText(analysis: {
136
138
  state: string;
137
139
  reason: string | null;
138
140
  }): string;
141
+ /** Preserve the run's own result while making requested post-processing failures machine-visible. */
139
142
  export declare function automaticAnalysisEnvelope<T>(result: T): T;
package/dist/cli/io.js CHANGED
@@ -1,5 +1,5 @@
1
- import { automaticAnalysisSucceeded, defaultAnalysisOverBudget, } from "../analysis/automatic-completion.js";
2
- import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
1
+ import { automaticAnalysisSucceeded, } from "../analysis/automatic-completion.js";
2
+ import { admittingMaxCost, costRefusalText, refusedCost, } from "../analysis/admission.js";
3
3
  import { Command, Option } from "commander";
4
4
  import { loadEnvFile, recordDotenvNames } from "../keys/env-file.js";
5
5
  import { discoverProviderKeys } from "../keys/key-resolution.js";
@@ -188,13 +188,14 @@ export function writeResult(command, io, result, formatHuman) {
188
188
  writeHuman(command, io, formatHuman(output));
189
189
  if (output !== null && typeof output === "object" && "automaticAnalysis" in output) {
190
190
  const analysis = output.automaticAnalysis;
191
- if (analysis)
191
+ const cost = analysis?.result?.admission && refusedCost(analysis.result.admission);
192
+ if (cost)
193
+ io.writeOut(`analysis: refused before it started. ${refusedAnalysisText(cost, analysis.result?.run ?? "latest")}\n`);
194
+ else if (analysis)
192
195
  io.writeOut(`analysis: ${analysisOutcomeText(analysis)}\n`);
193
196
  const rejected = analysis?.result?.rejectedOutputPath;
194
197
  if (rejected)
195
198
  io.writeOut(`analysis: ${analysis.result?.error?.code ?? "rejected"}; the rejected output is kept locally at ${rejected}\n`);
196
- if (defaultAnalysisOverBudget(output))
197
- io.writeOut(overBudgetAnalysisHint(output));
198
199
  }
199
200
  }
200
201
  markInvocationEnvelopeWritten(command);
@@ -286,7 +287,7 @@ const ANALYSIS_REASON_TEXT = {
286
287
  AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE: "skipped because the run's evidence could not be read",
287
288
  AUTOMATIC_ANALYSIS_ALREADY_REQUESTED: "skipped because this run's analysis was already requested",
288
289
  AUTOMATIC_ANALYSIS_BUSY: "skipped because another analysis is running",
289
- AUTOMATIC_ANALYSIS_ADMISSION_REFUSED: "not started because its configuration or question was refused",
290
+ AUTOMATIC_ANALYSIS_ADMISSION_REFUSED: "refused before it started: its configuration, question, evidence or cost did not pass admission",
290
291
  AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED: "not started because the run's cleanup was not confirmed",
291
292
  AUTOMATIC_ANALYSIS_REUSED: "complete, reusing an earlier analysis of the same evidence",
292
293
  AUTOMATIC_ANALYSIS_LIMITATIONS: "partial, because it covered only part of the evidence",
@@ -305,13 +306,11 @@ export function analysisOutcomeText(analysis) {
305
306
  return analysis.state;
306
307
  return ANALYSIS_REASON_TEXT[analysis.reason] ?? `${analysis.state} (${analysis.reason})`;
307
308
  }
308
- /** Preserve the run's own result while making requested post-processing failures machine-visible. */
309
- function overBudgetAnalysisHint(result) {
310
- const estimate = result.automaticAnalysis?.result?.admission?.estimatedCostUsd ?? null;
311
- const suggested = Math.ceil(estimate ?? DEFAULT_ANALYSIS_MAX_COST_USD + 1);
312
- const shown = estimate === null ? "" : ` ($${estimate.toFixed(2)})`;
313
- return `analysis: its estimate${shown} is over the default $${DEFAULT_ANALYSIS_MAX_COST_USD} cap, so no request was sent. To analyze this run: ${cli(`analyze --run ${result.runId ?? "latest"} --max-cost ${suggested}`)}\n`;
309
+ /** What a refused analysis would cost and the command that runs it with a cap that admits it. */
310
+ function refusedAnalysisText(cost, runId) {
311
+ return `${costRefusalText(cost)} To run it, raise the cap: ${cli(`analyze --run ${runId} --max-cost ${admittingMaxCost(cost)}`)}`;
314
312
  }
313
+ /** Preserve the run's own result while making requested post-processing failures machine-visible. */
315
314
  export function automaticAnalysisEnvelope(result) {
316
315
  if (result === null ||
317
316
  typeof result !== "object" ||
@@ -2,16 +2,18 @@ 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";
12
13
  import { registerFeedbackCommands } from "./commands/feedback.js";
13
14
  import { registerStudyCommands } from "./commands/study.js";
14
15
  import { registerMigrateCommand } from "./commands/migrate.js";
16
+ import { registerNotesCommand } from "./commands/notes.js";
15
17
  import { registerObserveCommand } from "./commands/observe.js";
16
18
  import { registerCleanupCommand, registerExportCommand, registerReclaimCommand, registerReviewCommand, registerRunCommand, registerRunsCommand, registerRuntimeCommands, registerStatsCommand, registerVerifyCommand, } from "./commands/runs.js";
17
19
  import { registerDoctorCommand, registerInitCommand, registerKeysCommand, registerTelemetryCommand, } from "./commands/setup.js";
@@ -95,6 +97,29 @@ async function recordCommandTelemetry(command, ok, durationMs, exitCode) {
95
97
  // Metrics are our problem, never the operator's.
96
98
  }
97
99
  }
100
+ /**
101
+ * After a command, one line on stderr when a newer humanish is published. It reads only the cache;
102
+ * the registry request runs detached (see update-check.ts), so nothing here waits on the network.
103
+ */
104
+ function noticeNewerVersion(command, io) {
105
+ try {
106
+ const notice = checkForUpdate({
107
+ installed: CLI_VERSION,
108
+ env: process.env,
109
+ terminal: process.stderr.isTTY === true,
110
+ json: wantsJson(command),
111
+ ownCheckout: isOwnCheckoutRun(process.cwd(), dirname(fileURLToPath(import.meta.url)), (p) => readFileSync(p, "utf8")),
112
+ now: Date.now(),
113
+ startRefresh: startRefreshWorker,
114
+ ...io.updateCheck,
115
+ });
116
+ if (notice !== undefined)
117
+ io.writeErr(`${notice}\n`);
118
+ }
119
+ catch {
120
+ // An update notice is never a reason for a command to fail.
121
+ }
122
+ }
98
123
  /**
99
124
  * HUMANISH_DEBUG_HANDLES=1: after a command's handler settles, name what is still keeping the
100
125
  * process alive. A live terminal run wrote its result 64 s in and the CLI stayed up for
@@ -165,6 +190,7 @@ class HumanishCommand extends Command {
165
190
  const noticed = announceTelemetryOnce(this, cliIo);
166
191
  const finish = (ok) => {
167
192
  void recordCommandTelemetry(this, ok, Date.now() - startedAt, lastExitCode);
193
+ noticeNewerVersion(this, cliIo);
168
194
  reportActiveHandles(this, cliIo);
169
195
  };
170
196
  try {
@@ -326,6 +352,15 @@ export function createProgram(io = {}) {
326
352
  // each subcommand when it is created.
327
353
  .helpOption("-h, --help", "Show help for this command.")
328
354
  .helpCommand("help [command]", "Show help for a command.")
355
+ // `help <command>` and `<command> help` keep working; a command's listing names only the
356
+ // commands that do something, so `help` is left out of every Commands section.
357
+ .configureHelp({
358
+ visibleCommands(command) {
359
+ return Help.prototype.visibleCommands
360
+ .call(this, command)
361
+ .filter((child) => child.name() !== "help");
362
+ },
363
+ })
329
364
  .showHelpAfterError()
330
365
  .option("--json", "Print machine-readable JSON responses where supported.")
331
366
  .configureOutput({
@@ -358,6 +393,7 @@ export function createProgram(io = {}) {
358
393
  registerVerifyCommand(program, cliIo);
359
394
  registerReviewCommand(program, cliIo);
360
395
  registerAnalyzeCommand(program, cliIo);
396
+ registerNotesCommand(program, cliIo);
361
397
  registerFeedbackCommands(program, cliIo);
362
398
  registerExportCommand(program, cliIo);
363
399
  registerRunsCommand(program, cliIo);
@@ -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;