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,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"}`,
@@ -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"]);
@@ -4,14 +4,13 @@ import type { AnalysisArtifact, LoadedAnalysis } from "../analysis/types.js";
4
4
  import { type CliIo } from "./io.js";
5
5
  export declare const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
6
6
  type Finding = NonNullable<AnalysisArtifact["result"]>["findings"][number];
7
+ type DesignFinding = NonNullable<NonNullable<AnalysisArtifact["result"]>["designFindings"]>[number];
7
8
  /** `ready` and `stale` carry findings; every other state carries none and says why in `message`. */
8
9
  type FindingsState = "ready" | "stale" | "running" | "none" | "skipped" | "failed" | "dry_run" | "unavailable";
9
- interface FindingEvidence {
10
+ interface CitedEvidence {
10
11
  id: string;
11
12
  streamId: string;
12
13
  kind: string;
13
- /** How the finding's observations used this item: visual, action, participant_statement, inference. */
14
- bases: string[];
15
14
  /** Index of the latest retained capture at this item, from 0; null before any capture. */
16
15
  frame: number | null;
17
16
  /** Time since the first retained capture, not a video offset. */
@@ -20,8 +19,19 @@ interface FindingEvidence {
20
19
  /** The capture file relative to the project directory, or null for nonvisual evidence. */
21
20
  capture: string | null;
22
21
  }
22
+ interface FindingEvidence extends CitedEvidence {
23
+ /** How the finding's observations used this item: visual, action, participant_statement, inference. */
24
+ bases: string[];
25
+ }
23
26
  interface FindingView {
24
27
  id: string;
28
+ /**
29
+ * One plain sentence about what happened, and one to three about what the person tried and what
30
+ * got in the way. Null in analyses written before them. An amended finding keeps the original
31
+ * text here; `correction.replacementClaim` replaces both.
32
+ */
33
+ headline: string | null;
34
+ experience: string | null;
25
35
  title: string;
26
36
  summary: string;
27
37
  impact: Finding["impact"];
@@ -41,6 +51,22 @@ interface FindingView {
41
51
  replacementClaim: string | null;
42
52
  } | null;
43
53
  }
54
+ interface DesignFindingView {
55
+ id: string;
56
+ headline: string;
57
+ screen: string;
58
+ notice: string;
59
+ whyItMatters: string;
60
+ suggestion: string;
61
+ severity: DesignFinding["severity"];
62
+ confidence: DesignFinding["confidence"];
63
+ seenBy: {
64
+ streamId: string;
65
+ label: string;
66
+ }[];
67
+ /** Each cited item once; at least one has a capture. */
68
+ evidence: CitedEvidence[];
69
+ }
44
70
  export interface AnalysisFindings {
45
71
  schema: typeof ANALYSIS_FINDINGS_SCHEMA;
46
72
  runId: string;
@@ -66,6 +92,11 @@ export interface AnalysisFindings {
66
92
  summary: string | null;
67
93
  /** Highest priority first, as the analysis ranked them. */
68
94
  findings: FindingView[];
95
+ /**
96
+ * Problems a designer would see in the captures, most severe first. Null when the view carries
97
+ * no analysis result, or the analysis was written before design findings.
98
+ */
99
+ designFindings: DesignFindingView[] | null;
69
100
  limitations: string[];
70
101
  warnings: string[];
71
102
  }
@@ -27,16 +27,10 @@ function analyzeCommand(source, provider) {
27
27
  ? `${run} --provider codex`
28
28
  : `${run} --max-cost ${DEFAULT_ANALYSIS_MAX_COST_USD}`;
29
29
  }
30
- function evidenceOf(finding, analysis, runRoot) {
31
- const bases = new Map();
32
- for (const observation of finding.observations)
33
- for (const id of observation.evidenceIds) {
34
- const set = bases.get(id) ?? new Set();
35
- set.add(observation.basis);
36
- bases.set(id, set);
37
- }
30
+ /** The cited items the analysis evidence resolves, each once, in citation order. */
31
+ function citedEvidence(ids, analysis, runRoot) {
38
32
  const byId = new Map(analysis.evidence.map((entry) => [entry.id, entry]));
39
- return [...bases].flatMap(([id, used]) => {
33
+ return [...new Set(ids)].flatMap((id) => {
40
34
  const entry = byId.get(id);
41
35
  if (!entry)
42
36
  return [];
@@ -45,7 +39,6 @@ function evidenceOf(finding, analysis, runRoot) {
45
39
  id,
46
40
  streamId: entry.streamId,
47
41
  kind: entry.kind,
48
- bases: [...used],
49
42
  frame: entry.frame,
50
43
  elapsedMs: entry.elapsedMs,
51
44
  at: entry.at,
@@ -54,11 +47,58 @@ function evidenceOf(finding, analysis, runRoot) {
54
47
  ];
55
48
  });
56
49
  }
50
+ function evidenceOf(finding, analysis, runRoot) {
51
+ const bases = new Map();
52
+ for (const observation of finding.observations)
53
+ for (const id of observation.evidenceIds) {
54
+ const set = bases.get(id) ?? new Set();
55
+ set.add(observation.basis);
56
+ bases.set(id, set);
57
+ }
58
+ return citedEvidence(bases.keys(), analysis, runRoot).map(({ id, streamId, kind, ...entry }) => ({
59
+ id,
60
+ streamId,
61
+ kind,
62
+ bases: [...bases.get(id)],
63
+ ...entry,
64
+ }));
65
+ }
66
+ const SEVERITY_RANK = {
67
+ major: 0,
68
+ moderate: 1,
69
+ minor: 2,
70
+ };
71
+ /** Most severe first; equal severity keeps the analysis order. */
72
+ function designFindingViews(analysis, runRoot) {
73
+ const designFindings = analysis.result?.designFindings;
74
+ if (designFindings === undefined)
75
+ return null;
76
+ const labels = new Map(analysis.participants.map((p) => [p.streamId, p.label]));
77
+ return [...designFindings]
78
+ .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])
79
+ .map((finding) => ({
80
+ id: finding.id,
81
+ headline: finding.headline,
82
+ screen: finding.screen,
83
+ notice: finding.notice,
84
+ whyItMatters: finding.whyItMatters,
85
+ suggestion: finding.suggestion,
86
+ severity: finding.severity,
87
+ confidence: finding.confidence,
88
+ seenBy: finding.seenByStreamIds.map((streamId) => ({
89
+ streamId,
90
+ label: labels.get(streamId) ?? streamId,
91
+ })),
92
+ evidence: citedEvidence(finding.evidenceIds, analysis, runRoot),
93
+ }));
94
+ }
57
95
  function findingView(finding, loaded, runRoot) {
58
96
  const labels = new Map(loaded.analysis.participants.map((p) => [p.streamId, p.label]));
59
97
  const correction = loaded.corrections.filter((entry) => entry.findingId === finding.id).at(-1);
60
98
  return {
61
99
  id: finding.id,
100
+ headline: finding.headline ?? null,
101
+ experience: finding.experience ?? null,
62
102
  title: finding.title,
63
103
  summary: finding.summary,
64
104
  impact: finding.impact,
@@ -99,6 +139,7 @@ function withoutFindings(source, state, reason, message, next) {
99
139
  path: null,
100
140
  summary: null,
101
141
  findings: [],
142
+ designFindings: null,
102
143
  limitations: [],
103
144
  warnings: source.loaded.warnings,
104
145
  };
@@ -142,6 +183,7 @@ export function analysisFindings(source) {
142
183
  path: path.join(source.runRoot, "analysis", analysis.id, "analysis.json"),
143
184
  summary: result.summary,
144
185
  findings: result.findings.map((finding) => findingView(finding, { ...loaded, analysis }, source.runRoot)),
186
+ designFindings: designFindingViews(analysis, source.runRoot),
145
187
  limitations: result.limitations,
146
188
  warnings: loaded.warnings,
147
189
  };
@@ -288,20 +330,49 @@ function momentsText(evidence) {
288
330
  parts.push(`${plural(unframed, "item")} with no frame`);
289
331
  return parts.join(", ");
290
332
  }
333
+ /** The capture files, relative to the run directory, in frame order. */
334
+ function captureFiles(evidence, runRoot) {
335
+ return [
336
+ ...new Set(evidence
337
+ .filter((entry) => entry.capture !== null)
338
+ .sort((a, b) => (a.frame ?? 0) - (b.frame ?? 0))
339
+ .map((entry) => (runRoot ? path.relative(runRoot, entry.capture) : entry.capture))),
340
+ ];
341
+ }
342
+ /**
343
+ * What a reader sees first: the headline, or the reviewer's claim when a review amended the finding,
344
+ * since the headline and experience describe the claim it replaced. Null before headlines existed.
345
+ */
346
+ function leadOf(finding) {
347
+ const amended = finding.correction?.status === "amended" ? finding.correction : null;
348
+ if (finding.headline === null)
349
+ return null;
350
+ return amended?.replacementClaim
351
+ ? { headline: amended.replacementClaim, corrected: true }
352
+ : { headline: finding.headline, corrected: false };
353
+ }
291
354
  function findingLines(finding, runRoot) {
292
355
  const cited = [...new Set(finding.evidence.map((entry) => entry.streamId))];
293
356
  const labels = new Map(finding.affected.map((p) => [p.streamId, p.label]));
294
357
  const affectedIds = finding.affected.map((p) => p.streamId);
295
358
  const streams = [...affectedIds, ...cited.filter((id) => !labels.has(id))];
296
- const captures = [
297
- ...new Set(finding.evidence
298
- .filter((entry) => entry.capture !== null)
299
- .sort((a, b) => (a.frame ?? 0) - (b.frame ?? 0))
300
- .map((entry) => (runRoot ? path.relative(runRoot, entry.capture) : entry.capture))),
301
- ];
359
+ const captures = captureFiles(finding.evidence, runRoot);
302
360
  const correction = finding.correction;
361
+ const lead = leadOf(finding);
303
362
  return [
304
- `${finding.id} ${finding.title}`,
363
+ ...(lead === null
364
+ ? [`${finding.id} ${finding.title}`]
365
+ : [
366
+ `${finding.id} ${lead.headline}`,
367
+ ...(lead.corrected
368
+ ? [
369
+ " Corrected in human review. The reviewer's claim replaces the original headline and account.",
370
+ ]
371
+ : finding.experience === null
372
+ ? []
373
+ : [` ${finding.experience}`]),
374
+ ` evidence: ${finding.title}`,
375
+ ]),
305
376
  ` impact: ${IMPACT_TEXT[finding.impact]} · confidence: ${finding.confidence} · recovery: ${RECOVERY_TEXT[finding.recovery]}`,
306
377
  ` ${finding.summary}`,
307
378
  ` affected: ${finding.affected.length} of ${plural(finding.exposedCount, "exposed participant")}`,
@@ -319,6 +390,34 @@ function findingLines(finding, runRoot) {
319
390
  ]),
320
391
  ];
321
392
  }
393
+ function designFindingLines(finding, runRoot) {
394
+ const captures = captureFiles(finding.evidence, runRoot);
395
+ return [
396
+ `${finding.id} ${finding.severity}: ${finding.headline}`,
397
+ ` screen: ${finding.screen}`,
398
+ ` notice: ${finding.notice}`,
399
+ ` why it matters: ${finding.whyItMatters}`,
400
+ ` suggestion: ${finding.suggestion}`,
401
+ ` seen by: ${finding.seenBy.map((p) => p.label).join(", ")} · confidence: ${finding.confidence}`,
402
+ ...(captures.length === 0 ? [] : [` captures: ${captures.join(", ")}`]),
403
+ ];
404
+ }
405
+ /** Nothing for an analysis written before design findings. */
406
+ function designSection(view) {
407
+ const design = view.designFindings;
408
+ if (design === null)
409
+ return [];
410
+ if (design.length === 0)
411
+ return ["", "design findings: none in the reviewed captures"];
412
+ return [
413
+ "",
414
+ `design findings: ${design.length}, most severe first`,
415
+ ...design.flatMap((finding, index) => [
416
+ ...(index === 0 ? [] : [""]),
417
+ ...designFindingLines(finding, view.runPath ?? ""),
418
+ ]),
419
+ ];
420
+ }
322
421
  /** The full findings block: the analysis, every finding with its evidence, and its limitations. */
323
422
  export function formatFindings(view) {
324
423
  if (view.state !== "ready" && view.state !== "stale")
@@ -330,6 +429,7 @@ export function formatFindings(view) {
330
429
  ...(view.state === "stale" ? [`warning: ${view.message}`, `next: ${view.next}`] : []),
331
430
  ...(view.summary ? [view.summary] : []),
332
431
  ...view.findings.flatMap((finding) => ["", ...findingLines(finding, view.runPath ?? "")]),
432
+ ...designSection(view),
333
433
  ...(view.limitations.length === 0
334
434
  ? []
335
435
  : ["", "limitations:", ...view.limitations.map((limitation) => `- ${limitation}`)]),
@@ -343,12 +443,30 @@ export function formatFindingsSummary(view, fullCommand, limit = 3) {
343
443
  if (view.state !== "ready" && view.state !== "stale")
344
444
  return [`findings: ${view.message}`, ...(view.next ? [`next: ${view.next}`] : [])];
345
445
  const shown = view.findings.slice(0, limit);
446
+ const design = view.designFindings;
346
447
  return [
347
448
  `findings: ${view.findings.length === 0 ? "none" : view.findings.length}${view.state === "stale" ? " (stale)" : ""}`,
348
- ...shown.map((finding) => `- ${finding.id} ${IMPACT_TEXT[finding.impact]}, ${finding.confidence} confidence, ${RECOVERY_TEXT[finding.recovery]}: ${finding.title}`),
449
+ ...shown.map((finding) => {
450
+ const qualities = `${IMPACT_TEXT[finding.impact]}, ${finding.confidence} confidence, ${RECOVERY_TEXT[finding.recovery]}`;
451
+ const lead = leadOf(finding);
452
+ if (lead === null)
453
+ return `- ${finding.id} ${qualities}: ${finding.title}`;
454
+ return `- ${finding.id} ${lead.headline} (${lead.corrected ? "corrected in human review" : qualities})`;
455
+ }),
349
456
  ...(view.findings.length > limit
350
457
  ? [`- and ${plural(view.findings.length - limit, "more finding")}`]
351
458
  : []),
459
+ ...(design === null
460
+ ? []
461
+ : [
462
+ `design findings: ${design.length === 0 ? "none" : design.length}`,
463
+ ...design
464
+ .slice(0, limit)
465
+ .map((finding) => `- ${finding.id} ${finding.severity}: ${finding.headline}`),
466
+ ...(design.length > limit
467
+ ? [`- and ${plural(design.length - limit, "more design finding")}`]
468
+ : []),
469
+ ]),
352
470
  `all findings: ${fullCommand}`,
353
471
  ];
354
472
  }
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.";
@@ -18,7 +20,8 @@ export declare const DOTENV_OPTION_DESCRIPTION = "Load unset variables from this
18
20
  * The hidden `--env-file <path>` that `--dotenv` replaced. Node scans the whole argv for
19
21
  * `--env-file` before a script runs, and stops only at `--` (src/node_dotenv.cc), so a missing file
20
22
  * exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
21
- * Removed in 0.112.0: deprecated in 0.110.0 on 2026-10-03, it stays 30 days and one minor release.
23
+ * Removed in the first minor release on or after 2026-11-03: deprecated in 0.110.0 on 2026-10-03, it stays at least 30 days and one
24
+ * minor release.
22
25
  */
23
26
  export declare function envFileAliasOption(): Option;
24
27
  /** The dotenv path a command was given: `--dotenv`, or the `--env-file` alias with its warning. */
package/dist/cli/io.js CHANGED
@@ -15,12 +15,13 @@ export const JSON_OPTION_DESCRIPTION = "Print a machine-readable JSON response."
15
15
  export const CWD_OPTION_DESCRIPTION = "Project directory.";
16
16
  export const RUN_OPTION_DESCRIPTION = "Run id, or latest.";
17
17
  export const DOTENV_OPTION_DESCRIPTION = "Load unset variables from this dotenv file. Values are never printed or saved.";
18
- const ENV_FILE_RENAMED = "`--env-file` is now `--dotenv`; `--env-file` is removed in 0.112.0.";
18
+ const ENV_FILE_RENAMED = "`--env-file` is now `--dotenv`; `--env-file` is removed in the first minor release on or after 2026-11-03.";
19
19
  /**
20
20
  * The hidden `--env-file <path>` that `--dotenv` replaced. Node scans the whole argv for
21
21
  * `--env-file` before a script runs, and stops only at `--` (src/node_dotenv.cc), so a missing file
22
22
  * exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
23
- * Removed in 0.112.0: deprecated in 0.110.0 on 2026-10-03, it stays 30 days and one minor release.
23
+ * Removed in the first minor release on or after 2026-11-03: deprecated in 0.110.0 on 2026-10-03, it stays at least 30 days and one
24
+ * minor release.
24
25
  */
25
26
  export function envFileAliasOption() {
26
27
  return new Option("--env-file <path>").hideHelp().conflicts("dotenv");
@@ -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>;