humanish 0.111.4 → 0.112.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 (93) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/actors/computer-use/openai-conversation.d.ts +91 -0
  3. package/dist/actors/computer-use/openai-conversation.js +209 -0
  4. package/dist/actors/computer-use/openai-provider.js +25 -41
  5. package/dist/actors/contract.d.ts +2 -0
  6. package/dist/analysis/diagnostics.js +7 -12
  7. package/dist/analysis/execute.d.ts +1 -1
  8. package/dist/analysis/execute.js +35 -5
  9. package/dist/analysis/service.js +7 -1
  10. package/dist/analysis/types.d.ts +23 -0
  11. package/dist/analysis/validation.d.ts +80 -35
  12. package/dist/analysis/validation.js +58 -11
  13. package/dist/cli/commands/runs.js +4 -10
  14. package/dist/cli/findings.d.ts +34 -3
  15. package/dist/cli/findings.js +136 -18
  16. package/dist/cli/io.d.ts +2 -1
  17. package/dist/cli/io.js +3 -2
  18. package/dist/feedback/draft.js +14 -4
  19. package/dist/observer/run-routes.js +8 -4
  20. package/dist/observer-app.html +4 -4
  21. package/dist/routes/computer-use/bundle.d.ts +1 -1
  22. package/dist/routes/computer-use/bundle.js +6 -16
  23. package/dist/routes/computer-use/fanout-bundle.js +40 -37
  24. package/dist/routes/computer-use/fanout-records.d.ts +2 -0
  25. package/dist/routes/computer-use/fanout-records.js +18 -26
  26. package/dist/routes/computer-use/participant-execution.js +4 -11
  27. package/dist/routes/computer-use/participant-facts.d.ts +2 -2
  28. package/dist/routes/computer-use/participant-facts.js +10 -1
  29. package/dist/routes/computer-use/result.js +7 -15
  30. package/dist/routes/computer-use/single-bundle.d.ts +2 -0
  31. package/dist/routes/computer-use/single-bundle.js +81 -37
  32. package/dist/routes/scripted/bundle.d.ts +1 -1
  33. package/dist/routes/scripted/bundle.js +9 -19
  34. package/dist/routes/scripted/surface-records.d.ts +0 -1
  35. package/dist/routes/scripted/surface-records.js +27 -13
  36. package/dist/routes/shared-world/bundle-records.d.ts +2 -2
  37. package/dist/routes/shared-world/bundle-records.js +21 -28
  38. package/dist/routes/shared-world/bundle.d.ts +1 -1
  39. package/dist/routes/shared-world/bundle.js +70 -78
  40. package/dist/routes/shared-world/participant-specs.d.ts +1 -2
  41. package/dist/routes/shared-world/participant-specs.js +0 -8
  42. package/dist/routes/shared-world/result.js +8 -12
  43. package/dist/routes/terminal/bundle.d.ts +1 -1
  44. package/dist/routes/terminal/bundle.js +10 -19
  45. package/dist/routes/terminal/dry-run.js +1 -0
  46. package/dist/routes/terminal/live-sandbox.js +9 -2
  47. package/dist/routes/terminal/plan.js +1 -0
  48. package/dist/routes/terminal/runtime.d.ts +1 -0
  49. package/dist/routes/terminal/runtime.js +1 -0
  50. package/dist/routes/terminal/session.js +1 -0
  51. package/dist/run/costs.js +9 -20
  52. package/dist/run/detail.d.ts +1 -1
  53. package/dist/run/detail.js +10 -1
  54. package/dist/run/display.d.ts +2 -2
  55. package/dist/run/display.js +15 -18
  56. package/dist/run/dry-run.js +10 -25
  57. package/dist/run/judge.d.ts +24 -0
  58. package/dist/run/judge.js +54 -0
  59. package/dist/run/liveness.d.ts +18 -0
  60. package/dist/run/liveness.js +24 -0
  61. package/dist/run/outcomes.d.ts +8 -1
  62. package/dist/run/outcomes.js +58 -12
  63. package/dist/run/participant-caption.d.ts +30 -0
  64. package/dist/run/participant-caption.js +50 -0
  65. package/dist/run/participant-records.d.ts +1 -1
  66. package/dist/run/review-markdown.d.ts +11 -0
  67. package/dist/run/review-markdown.js +22 -0
  68. package/dist/run/run-cost.d.ts +7 -0
  69. package/dist/run/run-cost.js +25 -5
  70. package/dist/run/run-index.d.ts +4 -50
  71. package/dist/run/run-index.js +57 -87
  72. package/dist/run/run.d.ts +2 -2
  73. package/dist/run/run.js +5 -3
  74. package/dist/run/stats.js +2 -12
  75. package/dist/run/transient-comms-secrets.d.ts +8 -3
  76. package/dist/run/transient-comms-secrets.js +21 -1
  77. package/dist/study/keys.d.ts +1 -0
  78. package/dist/study/keys.js +1 -1
  79. package/dist/study/parse/execution.js +7 -1
  80. package/dist/study/plan-types.d.ts +1 -0
  81. package/dist/study/types.d.ts +4 -1
  82. package/dist/study/warnings.d.ts +2 -1
  83. package/dist/study/warnings.js +3 -2
  84. package/dist/tui-app.js +73 -73
  85. package/dist/verify/liveness.d.ts +2 -2
  86. package/dist/verify/liveness.js +7 -4
  87. package/docs/architecture/terminal-product-route.md +14 -0
  88. package/docs/contracts/run-bundle.md +10 -0
  89. package/docs/contracts/schemas.md +9 -1
  90. package/docs/contracts/study-analysis.md +71 -22
  91. package/package.json +2 -1
  92. package/dist/actors/computer-use/openai-requests.d.ts +0 -62
  93. package/dist/actors/computer-use/openai-requests.js +0 -144
package/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.112.0: Plain finding headlines, design findings, plain captions, DO_NOT_TRACK for terminal participants (2026-10-07)
12
+
13
+ humanish 0.112.0 writes findings and captions for someone who did not watch the session, and
14
+ terminal participants send `DO_NOT_TRACK=1` by default. Each analysis finding leads with a plain
15
+ headline and an account of what the person tried, what got in their way and how it seemed to feel;
16
+ its title, summary and observations stay underneath as the evidence. Analyses also report design
17
+ findings: problems a product designer would notice in the captures, each with a severity and at
18
+ least one cited capture. `review --json` carries them as `analysis.designFindings`, and the
19
+ analysis prompt is `study-evidence-7`. Participant captions name the person, such as "Lobby host,
20
+ phone", and every route's run summary says how many participants took part and how many reached
21
+ the goal. Terminal participant and product setup commands run with `DO_NOT_TRACK=1`; set
22
+ `execution.terminal.doNotTrack: false` to study a product's telemetry. `humanish runs` and the
23
+ Observer now read a run with no outcome and a running simulation as interrupted, as the TUI,
24
+ `stats` and `verify` already did. The `--env-file` and `cleanup` aliases are removed in the first
25
+ minor release on or after 2026-11-03.
26
+
27
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.112.0)
28
+
11
29
  ## 0.111.4: Encoded known values scrubbed, scripted traces scrubbed, a live command after a dry run (2026-10-06)
12
30
 
13
31
  humanish 0.111.4 scrubs a run's known values from more of its evidence and fixes three messages.
@@ -0,0 +1,91 @@
1
+ import type { CuaTurn, CuaTurnRequest } from "./loop.js";
2
+ import type { ReasoningEffort } from "../reasoning-effort.js";
3
+ import type { ActorConversation } from "../contract.js";
4
+ import { type OpenAiReasoningSummary } from "./openai-wire.js";
5
+ /** How a conversation starts. The model settings stay fixed for the whole session. */
6
+ export interface OpenAiConversationSettings {
7
+ readonly model: string;
8
+ readonly reasoningEffort: ReasoningEffort;
9
+ readonly maxOutputTokens?: number;
10
+ readonly safetyIdentifier?: string;
11
+ /** The summaries to ask for until the account or model rejects them; undefined asks for none. */
12
+ readonly reasoningSummary: OpenAiReasoningSummary | undefined;
13
+ /** Carry the conversation on the client from the first request (explicit_context). */
14
+ readonly zeroDataRetention: boolean;
15
+ /** The clock the trace's record reads. */
16
+ readonly now: () => number;
17
+ }
18
+ /**
19
+ * One request of the conversation. Its new items are built once, so every attempt at it, and the
20
+ * reply it accepts, see the same ones.
21
+ */
22
+ export interface ConversationRequest {
23
+ /** The body as the conversation stands now, so an attempt after a rejection shows its effect. */
24
+ body(): Record<string, unknown>;
25
+ /**
26
+ * The request was answered: records a participant request with what it carried, moves the
27
+ * conversation on to the reply and returns the reply's turn.
28
+ */
29
+ accept(reply: unknown): CuaTurn;
30
+ }
31
+ /** Which answer told the provider that the organization keeps no server-side conversation. */
32
+ export type ZdrRejection = NonNullable<ActorConversation["rejection"]>;
33
+ export declare class OpenAiConversation {
34
+ private readonly settings;
35
+ private lastResponseId;
36
+ /** computer_call ids the next request owes an output for. */
37
+ private pendingCallIds;
38
+ /** Accepted replies so far; the next request is number `replies + 1`. */
39
+ private replies;
40
+ /** The whole conversation, which explicit-context requests carry within its token budget. */
41
+ private readonly carried;
42
+ private mode;
43
+ /** Undefined once the account or model rejected reasoning summaries. */
44
+ private reasoningSummary;
45
+ private readonly evidence;
46
+ constructor(settings: OpenAiConversationSettings);
47
+ /**
48
+ * The next request, carrying `req`'s screen: the opening request, or the outputs the latest
49
+ * reply's computer calls are owed. A closing report asks for a strict JSON report and no tools.
50
+ */
51
+ request(req: CuaTurnRequest, closing?: boolean): ConversationRequest;
52
+ /**
53
+ * The organization rejected server-side state: carry the conversation on the client from the
54
+ * request being sent, and record which answer made the switch. False when the conversation is
55
+ * already carried, so the rejection stands.
56
+ */
57
+ switchToExplicitContext(rejection: ZdrRejection): boolean;
58
+ /**
59
+ * The account or model rejected reasoning summaries: stop asking for the rest of the session,
60
+ * from the request being sent. False when none are asked for, so the rejection stands.
61
+ */
62
+ dropReasoningSummaries(): boolean;
63
+ /**
64
+ * Whether the server holds the whole session, so a closing report threaded on it reads every
65
+ * turn. An explicit-context conversation summarizes its oldest turns past its budget, configured
66
+ * or switched to at runtime.
67
+ */
68
+ get serverHoldsSession(): boolean;
69
+ /** The trace's account of how the conversation was carried (ActorConversation). */
70
+ record(): ActorConversation;
71
+ /**
72
+ * The items a request adds to the conversation: the opening message with the first screen, or
73
+ * an output for each pending computer_call with the latest screenshot and any hint.
74
+ */
75
+ private newItems;
76
+ private context;
77
+ /**
78
+ * A participant request: the opening request, or a continuation carrying `sent`. A threaded
79
+ * continuation names the previous response; an explicit-context one carries the conversation
80
+ * ahead of `sent`.
81
+ */
82
+ private turnBody;
83
+ /** The read-only closing report: no tools, a small output limit, a strict schema. */
84
+ private closingBody;
85
+ /**
86
+ * Move the conversation on to a reply to a request that added `sent`. A participant reply cut
87
+ * off by the output limit is set aside, so the next request is the same one again
88
+ * (outputLimitRetry); its actions never run, so no call output is owed for them.
89
+ */
90
+ private accept;
91
+ }
@@ -0,0 +1,209 @@
1
+ import { CarriedConversation, estimateTokens } from "./openai-context.js";
2
+ import { buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, openingMessage, parseOpenAiResponse, } from "./openai-wire.js";
3
+ /** The closing report's output limit: its own cap, or the declared limit when that is lower. */
4
+ const CLOSING_OUTPUT_LIMIT = 1024;
5
+ export class OpenAiConversation {
6
+ settings;
7
+ lastResponseId;
8
+ /** computer_call ids the next request owes an output for. */
9
+ pendingCallIds = [];
10
+ /** Accepted replies so far; the next request is number `replies + 1`. */
11
+ replies = 0;
12
+ /** The whole conversation, which explicit-context requests carry within its token budget. */
13
+ carried = new CarriedConversation();
14
+ mode;
15
+ /** Undefined once the account or model rejected reasoning summaries. */
16
+ reasoningSummary;
17
+ evidence;
18
+ constructor(settings) {
19
+ this.settings = settings;
20
+ this.mode = settings.zeroDataRetention ? "explicit_context" : "threaded";
21
+ this.reasoningSummary = settings.reasoningSummary;
22
+ this.evidence = new ConversationRecord(settings.zeroDataRetention, settings.now);
23
+ }
24
+ /**
25
+ * The next request, carrying `req`'s screen: the opening request, or the outputs the latest
26
+ * reply's computer calls are owed. A closing report asks for a strict JSON report and no tools.
27
+ */
28
+ request(req, closing = false) {
29
+ const sent = this.newItems(req);
30
+ return {
31
+ body: () => (closing ? this.closingBody(req, sent) : this.turnBody(req, sent)),
32
+ accept: (reply) => this.accept(reply, closing, sent),
33
+ };
34
+ }
35
+ /**
36
+ * The organization rejected server-side state: carry the conversation on the client from the
37
+ * request being sent, and record which answer made the switch. False when the conversation is
38
+ * already carried, so the rejection stands.
39
+ */
40
+ switchToExplicitContext(rejection) {
41
+ if (this.mode === "explicit_context")
42
+ return false;
43
+ this.evidence.switched(rejection);
44
+ this.mode = "explicit_context";
45
+ return true;
46
+ }
47
+ /**
48
+ * The account or model rejected reasoning summaries: stop asking for the rest of the session,
49
+ * from the request being sent. False when none are asked for, so the rejection stands.
50
+ */
51
+ dropReasoningSummaries() {
52
+ if (this.reasoningSummary === undefined)
53
+ return false;
54
+ this.reasoningSummary = undefined;
55
+ return true;
56
+ }
57
+ /**
58
+ * Whether the server holds the whole session, so a closing report threaded on it reads every
59
+ * turn. An explicit-context conversation summarizes its oldest turns past its budget, configured
60
+ * or switched to at runtime.
61
+ */
62
+ get serverHoldsSession() {
63
+ return this.mode === "threaded" && this.lastResponseId !== undefined;
64
+ }
65
+ /** The trace's account of how the conversation was carried (ActorConversation). */
66
+ record() {
67
+ return this.evidence.snapshot(this.mode);
68
+ }
69
+ /**
70
+ * The items a request adds to the conversation: the opening message with the first screen, or
71
+ * an output for each pending computer_call with the latest screenshot and any hint.
72
+ */
73
+ newItems(req) {
74
+ if (this.replies === 0)
75
+ return [openingMessage(req.instructions, req.observation.screenshot)];
76
+ return [
77
+ ...this.pendingCallIds.map((id) => buildCallOutput(id, req.observation.screenshot, req.acknowledgedSafetyChecks)),
78
+ ...hintItems(req.contextHint),
79
+ ];
80
+ }
81
+ context(instructions) {
82
+ const { settings } = this;
83
+ return {
84
+ stateless: this.mode === "explicit_context",
85
+ model: settings.model,
86
+ instructions,
87
+ reasoningEffort: settings.reasoningEffort,
88
+ ...(settings.maxOutputTokens === undefined
89
+ ? {}
90
+ : { maxOutputTokens: settings.maxOutputTokens }),
91
+ ...(this.reasoningSummary === undefined ? {} : { reasoningSummary: this.reasoningSummary }),
92
+ ...(settings.safetyIdentifier === undefined
93
+ ? {}
94
+ : { safetyIdentifier: settings.safetyIdentifier }),
95
+ };
96
+ }
97
+ /**
98
+ * A participant request: the opening request, or a continuation carrying `sent`. A threaded
99
+ * continuation names the previous response; an explicit-context one carries the conversation
100
+ * ahead of `sent`.
101
+ */
102
+ turnBody(req, sent) {
103
+ const ctx = this.context(req.instructions);
104
+ if (this.replies === 0)
105
+ return buildInitialRequest(ctx, req.observation.screenshot);
106
+ return buildContinuationRequest({
107
+ ctx,
108
+ previousResponseId: this.lastResponseId,
109
+ callOutputs: [...sent],
110
+ ...(this.mode === "explicit_context" ? { explicitContextItems: this.carried.carried() } : {}),
111
+ });
112
+ }
113
+ /** The read-only closing report: no tools, a small output limit, a strict schema. */
114
+ closingBody(req, sent) {
115
+ return {
116
+ ...this.turnBody(req, sent),
117
+ tool_choice: "none",
118
+ max_output_tokens: Math.min(this.settings.maxOutputTokens ?? CLOSING_OUTPUT_LIMIT, CLOSING_OUTPUT_LIMIT),
119
+ text: {
120
+ format: {
121
+ type: "json_schema",
122
+ name: "participant_closing_report",
123
+ strict: true,
124
+ schema: {
125
+ type: "object",
126
+ additionalProperties: false,
127
+ required: ["summary", "frictionReports"],
128
+ properties: {
129
+ summary: { type: "string" },
130
+ frictionReports: { type: "array", items: { type: "string" } },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * Move the conversation on to a reply to a request that added `sent`. A participant reply cut
139
+ * off by the output limit is set aside, so the next request is the same one again
140
+ * (outputLimitRetry); its actions never run, so no call output is owed for them.
141
+ */
142
+ accept(reply, closing, sent) {
143
+ // Recorded before the reply is accepted: accepting can cut the carried history, and the record
144
+ // counts what this request carried.
145
+ if (!closing)
146
+ this.evidence.requested(this.mode, this.carried, sent);
147
+ const parsed = parseOpenAiResponse(reply);
148
+ if (!closing && parsed.turn.interruption === "output_limit")
149
+ return parsed.turn;
150
+ if (parsed.turn.responseId !== undefined)
151
+ this.lastResponseId = parsed.turn.responseId;
152
+ this.pendingCallIds = parsed.callIds;
153
+ this.replies += 1;
154
+ this.carried.accept(this.replies, sent, parsed.outputItems);
155
+ return parsed.turn;
156
+ }
157
+ }
158
+ /** The trace's account of how a session carried its conversation (ActorConversation). */
159
+ class ConversationRecord {
160
+ now;
161
+ explicitReason;
162
+ rejection;
163
+ switchedAt;
164
+ switchedAtRequest;
165
+ summarizedTurns = 0;
166
+ requests = [];
167
+ constructor(configured, now) {
168
+ this.now = now;
169
+ if (configured)
170
+ this.explicitReason = "configured";
171
+ }
172
+ /** The organization rejected server-side state, so the request being sent is explicit_context. */
173
+ switched(rejection) {
174
+ this.explicitReason = "zdr_rejection";
175
+ this.rejection = rejection;
176
+ this.switchedAt = new Date(this.now()).toISOString();
177
+ // Counted like requests[], which includes replies set aside at their output limit.
178
+ this.switchedAtRequest = this.requests.length + 1;
179
+ }
180
+ /** A participant request went out with `sent` after `conversation`. */
181
+ requested(mode, conversation, sent) {
182
+ if (mode !== "explicit_context") {
183
+ this.requests.push({ mode: "threaded" });
184
+ return;
185
+ }
186
+ const carried = conversation.size();
187
+ // Only summaries a request carried count; the conversation also trims after the last reply.
188
+ this.summarizedTurns = carried.notedTurns;
189
+ this.requests.push({
190
+ mode: "explicit_context",
191
+ carriedExchanges: carried.exchanges,
192
+ carriedScreenshots: carried.screenshots,
193
+ estimatedInputTokens: carried.estimatedTokens + estimateTokens(sent),
194
+ });
195
+ }
196
+ snapshot(mode) {
197
+ return {
198
+ mode,
199
+ ...(this.explicitReason === undefined ? {} : { explicitReason: this.explicitReason }),
200
+ ...(this.rejection === undefined ? {} : { rejection: this.rejection }),
201
+ ...(this.switchedAt === undefined ? {} : { switchedAt: this.switchedAt }),
202
+ ...(this.switchedAtRequest === undefined
203
+ ? {}
204
+ : { switchedAtRequest: this.switchedAtRequest }),
205
+ summarizedTurns: this.summarizedTurns,
206
+ requests: this.requests.map((request) => ({ ...request })),
207
+ };
208
+ }
209
+ }
@@ -1,16 +1,16 @@
1
1
  import { validClosingReport } from "./loop.js";
2
2
  import { ComputerUseAdmissionLimitError, isComputerUseAdmissionLimitError, } from "./admission-limit.js";
3
3
  import { ComputerUsePromptRefusedError } from "./provider-error.js";
4
- import { CarriedConversation } from "./openai-context.js";
5
- import { acceptReply, ConversationRecord, debriefRequestBody, turnInputItems, turnRequestBody, } from "./openai-requests.js";
6
- import { asRecord, optionalString, parseOpenAiResponse, } from "./openai-wire.js";
4
+ import { OpenAiConversation, } from "./openai-conversation.js";
5
+ import { asRecord, optionalString } from "./openai-wire.js";
7
6
  import { redactText } from "../../evidence/redaction.js";
8
7
  import { isMaxOutputTokens } from "../output-token-limit.js";
9
8
  import { prepareContainedOutputFile, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../../run/contained-output.js";
10
9
  import { OPENAI_RESPONSES_URL } from "../openai-endpoint.js";
11
10
  // A public-safe re-derivation of the OpenAI Responses API computer-use provider,
12
11
  // behind the CuaProvider port from src/actors/computer-use/loop.ts. The pure wire mapping
13
- // (openAiActionToCua, parseOpenAiResponse and the request builders) is in openai-wire.ts.
12
+ // (openAiActionToCua, parseOpenAiResponse and the request builders) is in openai-wire.ts, and
13
+ // what each request carries, with the trace's record of it, is in openai-conversation.ts.
14
14
  // This module is the live shim: createOpenAiResponsesProvider does a raw POST to the
15
15
  // Responses endpoint (no SDK dependency) through an injectable FetchLike seam, so the retry,
16
16
  // ZDR fallback, and state threading are testable with a fake.
@@ -412,24 +412,17 @@ export function createOpenAiResponsesProvider(options) {
412
412
  // An unset or empty capture variable leaves capture off.
413
413
  const capture = createWireCapture(optionalString((options.env ?? process.env)[WIRE_CAPTURE_ENV]?.trim()));
414
414
  let interactionUsageIncomplete = false;
415
- const settings = {
415
+ const conversation = new OpenAiConversation({
416
416
  model,
417
417
  reasoningEffort,
418
418
  ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
419
419
  ...(options.safetyIdentifier === undefined
420
420
  ? {}
421
421
  : { safetyIdentifier: options.safetyIdentifier }),
422
- };
423
- const state = {
424
- lastResponseId: undefined,
425
- pendingCallIds: [],
426
- replies: 0,
427
- conversation: new CarriedConversation(),
428
- mode: options.zeroDataRetention ? "explicit_context" : "previous_response_id",
429
- // Latches to undefined (stop asking) for the rest of the session when the
430
- // account/model rejects the summary request; see OpenAiResponsesProviderOptions.
431
422
  reasoningSummary: options.reasoningSummary === "off" ? undefined : (options.reasoningSummary ?? "auto"),
432
- };
423
+ zeroDataRetention: options.zeroDataRetention === true,
424
+ now: options.now ?? Date.now,
425
+ });
433
426
  const transport = {
434
427
  endpoint: options.endpoint ?? OPENAI_RESPONSES_URL,
435
428
  apiKey: options.apiKey,
@@ -441,25 +434,21 @@ export function createOpenAiResponsesProvider(options) {
441
434
  },
442
435
  };
443
436
  const post = (body, signal, retries = maxRetries, interaction = true, spend) => postResponse(transport, body, signal, retries, interaction, spend);
444
- const record = new ConversationRecord(options.zeroDataRetention === true, options.now ?? Date.now);
445
437
  // POST with the recoverable-policy latches: a ZDR rejection switches to explicit-context mode; a
446
438
  // reasoning-summary rejection latches summaries off. Each latch can flip only once, so the loop
447
439
  // is bounded; anything else rethrows. The body is rebuilt per attempt so a flipped latch shows.
448
- const postTurn = async (req, sent, signal, spend) => {
440
+ const postTurn = async (request, signal, spend) => {
449
441
  if (options.singleDispatch === true)
450
- return post(turnRequestBody(settings, state, req, sent), signal, 0);
442
+ return post(request.body(), signal, 0);
451
443
  for (;;) {
452
444
  try {
453
- return await post(turnRequestBody(settings, state, req, sent), signal, maxRetries, true, spend);
445
+ return await post(request.body(), signal, maxRetries, true, spend);
454
446
  }
455
447
  catch (error) {
456
- if (error instanceof SummaryRejectionError && state.reasoningSummary !== undefined) {
457
- state.reasoningSummary = undefined;
448
+ if (error instanceof SummaryRejectionError && conversation.dropReasoningSummaries()) {
458
449
  continue;
459
450
  }
460
- if (error instanceof ZdrError && state.mode !== "explicit_context") {
461
- record.switched(error.rejection);
462
- state.mode = "explicit_context";
451
+ if (error instanceof ZdrError && conversation.switchToExplicitContext(error.rejection)) {
463
452
  continue;
464
453
  }
465
454
  throw error;
@@ -468,29 +457,26 @@ export function createOpenAiResponsesProvider(options) {
468
457
  };
469
458
  const requestTurn = async (req, signal, closing = false, spend) => {
470
459
  await capture.prepareNext();
471
- const sent = turnInputItems(state, req);
460
+ const request = conversation.request(req, closing);
472
461
  // A closing report makes exactly one request: no HTTP or policy-latch retries.
473
462
  const raw = closing
474
- ? await post(debriefRequestBody(settings, state, req, sent), signal, 0, false)
475
- : await postTurn(req, sent, signal, spend);
476
- if (!closing)
477
- record.requested(state, sent);
478
- const parsed = parseOpenAiResponse(raw);
479
- acceptReply(state, parsed, closing, sent);
463
+ ? await post(request.body(), signal, 0, false)
464
+ : await postTurn(request, signal, spend);
465
+ const turn = request.accept(raw);
480
466
  if (closing) {
481
467
  // Refusals, incomplete output, malformed JSON, and invalid shapes remain no-report results.
482
468
  // Never promote raw JSON or a fallback paragraph into a structured finding.
483
469
  try {
484
- const report = JSON.parse(parsed.turn.message ?? "");
470
+ const report = JSON.parse(turn.message ?? "");
485
471
  if (asRecord(raw).status === "completed" && validClosingReport(report)) {
486
- return { ...parsed.turn, closingReport: report };
472
+ return { ...turn, closingReport: report };
487
473
  }
488
474
  }
489
475
  catch {
490
476
  /* A failed optional closing account keeps its usage and no report. */
491
477
  }
492
478
  }
493
- return parsed.turn;
479
+ return turn;
494
480
  };
495
481
  return {
496
482
  id: "openai-responses-cu",
@@ -512,15 +498,13 @@ export function createOpenAiResponsesProvider(options) {
512
498
  },
513
499
  nextTurn: (req, signal, spend) => requestTurn(req, signal, false, spend),
514
500
  get conversation() {
515
- return record.snapshot(state);
501
+ return conversation.record();
516
502
  },
517
- // An explicit-context conversation summarizes its oldest turns past its budget, so it may not
518
- // hold the whole session a retrospective report needs. This getter follows both configured
519
- // ZDR and a runtime policy latch.
503
+ // A retrospective report reads the session the server holds; see serverHoldsSession.
520
504
  get debrief() {
521
- return state.mode === "explicit_context" || state.lastResponseId === undefined
522
- ? undefined
523
- : (req, signal) => requestTurn(req, signal, true);
505
+ return conversation.serverHoldsSession
506
+ ? (req, signal) => requestTurn(req, signal, true)
507
+ : undefined;
524
508
  },
525
509
  };
526
510
  }
@@ -191,6 +191,8 @@ export interface ParticipantClosingReport {
191
191
  export interface ActorRuntimeProvenance {
192
192
  schema: "humanish.actor-runtime.v1";
193
193
  package: string;
194
+ /** Terminal command telemetry opt-out; absent in older bundles. */
195
+ doNotTrack?: boolean;
194
196
  requestedVersion: string;
195
197
  observedVersion?: string;
196
198
  versionStatus: "unobserved" | "verified" | "failed";
@@ -1,9 +1,8 @@
1
1
  import { lstat, readdir, rmdir, unlink } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { decodeEscapes } from "../evidence/encoded-text.js";
4
3
  import { redactText } from "../evidence/redaction.js";
5
4
  import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, bindExistingManagedHumanishOutputDirectory, prepareManagedHumanishOutputDirectory, writeContainedOutputFile, } from "../run/contained-output.js";
6
- import { scrubTransientCommsText, transientCommsEncodedScrub, } from "../run/transient-comms-secrets.js";
5
+ import { transientCommsKnownValueScrub } from "../run/transient-comms-secrets.js";
7
6
  import { ANALYSIS_ID_PATTERN } from "./types.js";
8
7
  // A rejected analyst response is kept for diagnosis under .humanish/analysis-diagnostics/<run>/,
9
8
  // outside every run directory. Export, verify and the Observer read run directories only, so the
@@ -14,19 +13,15 @@ const ANALYSIS_DIAGNOSTIC_SCHEMA = "humanish.analysis-rejected-output.v1";
14
13
  /** How many rejected outputs are kept across all runs; older ones are removed on each write. */
15
14
  export const MAX_ANALYSIS_DIAGNOSTICS = 20;
16
15
  const REDACTED = "[REDACTED_SECRET]";
17
- const markers = (text) => text.split(REDACTED).length - 1;
18
16
  /**
19
- * Scrub every string, key and scalar in the output. The encoded scrub finds a known value written
20
- * percent-encoded, escaped or base64-encoded and returns decoded text; a string where it finds
21
- * nothing keeps its original spelling, so an escape in a rejected quote stays visible, and still
22
- * gets the literal scrub. A number or boolean equal to a known value becomes the marker.
17
+ * Scrub every string, key and scalar in the output. The known-value scrub finds a value written
18
+ * percent-encoded, escaped or base64-encoded; a string where it finds nothing keeps its original
19
+ * spelling, so an escape in a rejected quote stays visible. A number or boolean equal to a known
20
+ * value becomes the marker.
23
21
  */
24
22
  function scrubber() {
25
- const encoded = transientCommsEncodedScrub();
26
- const text = (value) => {
27
- const found = encoded(value);
28
- return redactText(markers(found) > markers(decodeEscapes(value)) ? found : scrubTransientCommsText(value));
29
- };
23
+ const known = transientCommsKnownValueScrub();
24
+ const text = (value) => redactText(known(value));
30
25
  const scrub = (value) => {
31
26
  if (typeof value === "string")
32
27
  return text(value);
@@ -1,7 +1,7 @@
1
1
  import { type AnalysisArtifact, type AnalysisConfig, type AnalysisInput } from "./types.js";
2
2
  import { type AnalysisFetch, type AnalysisProvider } from "./provider.js";
3
3
  import type { RejectedAnalysisOutput } from "./diagnostics.js";
4
- export declare const ANALYSIS_PROMPT_VERSION = "study-evidence-6";
4
+ export declare const ANALYSIS_PROMPT_VERSION = "study-evidence-7";
5
5
  export declare const isSupportedAnalysisModel: (model: string) => boolean;
6
6
  /** The OpenAI output limit when a study omits maxOutputTokens, and the most it may ask for. */
7
7
  export declare const DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS = 16384;
@@ -3,14 +3,14 @@ import { highDetailImageTokens } from "./image-tokens.js";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { estimateActorCost, MODEL_RATES } from "../run/pricing.js";
5
5
  import { containsSensitive } from "../evidence/redaction.js";
6
- import { scrubTransientCommsText } from "../run/transient-comms-secrets.js";
6
+ import { transientCommsKnownValueScrub } from "../run/transient-comms-secrets.js";
7
7
  import { ANALYSIS_ID_PATTERN, ANALYSIS_SCHEMA, } from "./types.js";
8
8
  import { INPUT_IMAGE_DATA_URL, createAnalysisProvider, } from "./provider.js";
9
9
  import { checkAnalysisResult, hashAnalysisValue, analysisResponseSchema, analysisResultJsonSchema, validateAnalysisInputMetadata, } from "./validation.js";
10
10
  import { EVIDENCE_LIMITS } from "./evidence.js";
11
11
  import { truncatedFrameWarning, unknownNotificationsWarning, } from "../actors/codex/restricted-notifications.js";
12
12
  import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
13
- export const ANALYSIS_PROMPT_VERSION = "study-evidence-6";
13
+ export const ANALYSIS_PROMPT_VERSION = "study-evidence-7";
14
14
  const SUPPORTED_ANALYSIS_MODELS = Object.freeze([
15
15
  "gpt-6-astra",
16
16
  "gpt-5.5",
@@ -55,7 +55,11 @@ Harness records describe the machinery running the study. Provisioning, runtime
55
55
 
56
56
  For each finding, consolidate repeated observations into one bounded problem or recovery. Each observation states one claim, labels its basis (visual, action, participant_statement, inference), cites supporting evidence, and declares its limitation. High confidence does not turn an inference into an observation. Show which distinct participants were affected and which were demonstrably exposed to the relevant interaction. Affected IDs must be a subset of exposed IDs, and both sets must be within included participants. Explain exposure; never automatically use the full panel as a denominator. Each affected participant must have supporting observation evidence.
57
57
 
58
- Keep every field as qualified as its evidence. This includes headlines, summaries, outcomes, impact, exposure, recovery, confidence and the premises of proposed next checks. A cautious observation cannot support an unqualified headline. When a useful concern is established only as participant feedback, say it was reported wherever the concern is summarized. A dispatched action establishes an attempt; its success needs resulting evidence. Use recovered only when the improvement is supported for the participants being grouped. If recovery differs across participants, describe those differences and use unknown for the combined recovery instead of erasing an unresolved case. Keep distinct problems separate, especially an unresolved result and a different problem that was corrected. Reversible exploration and unmeasured pauses are not automatically product defects.
58
+ Keep every evidence field as qualified as its evidence. This includes titles, summaries, outcomes, impact, exposure, recovery, confidence and the premises of proposed next checks. A cautious observation cannot support an unqualified title. When a useful concern is established only as participant feedback, say it was reported wherever the concern is summarized. A dispatched action establishes an attempt; its success needs resulting evidence. Use recovered only when the improvement is supported for the participants being grouped. If recovery differs across participants, describe those differences and use unknown for the combined recovery instead of erasing an unresolved case. Keep distinct problems separate, especially an unresolved result and a different problem that was corrected. Reversible exploration and unmeasured pauses are not automatically product defects.
59
+
60
+ Write for the person who will act on the report: a designer, product manager or developer who did not watch the session and does not know this tool's vocabulary. Give every finding a headline and an experience. The headline is one plain sentence about what happened to the people involved, as a colleague would say it out loud. The experience is one to three plain sentences: what the person was trying to do, what got in their way, and how it seemed to feel, using the participant's own words where a quote exists. Do not use participant stream IDs, evidence IDs, or this report's evidence vocabulary (exposure, exposed, stream, capture, corroborated, established, basis, provenance) in the headline or experience. Plain is not unqualified: a concern known only from what participants said reads as something they said ("Two players said they lost track of which control had focus"), and a cause the evidence does not show is not stated as fact. The title, summary and observations keep their current precision underneath.
61
+
62
+ Separately, review the supplied captures as an experienced product designer would and report designFindings: problems a designer would notice in what the screens show, whether or not any participant mentioned them. Look at the size of text and controls (too small to read or to hit comfortably at this window size), layout and density (spread too wide or packed too tight for the task), visual hierarchy, legibility and contrast, wording and labels, consistency between screens, feedback after actions, and anything that makes the product look unfinished or untrustworthy. Judge the screens as the participants' personas would meet them: who they are and what they came to do. Each design finding names the screen in plain words, says what a designer notices, why it matters to a person using the product, and one concrete suggestion. Each must cite at least one supplied capture that shows the problem, and seenByStreamIds lists only participants whose cited captures show it. Rate severity by the effect on a person using the product: major when it misleads or blocks, moderate when it slows or confuses, minor when it is polish. Report only what the captures show; no capture, no design finding. Use D1, D2, and so on as IDs. An empty designFindings array is correct when the captures show no design problem worth a designer's time. Write design findings in the same plain register as headlines.
59
63
 
60
64
  Order findings by observed task impact, replication among exposed participants, and recovery. Preserve severity and confidence as separate fields. Do not compute a numeric frustration score or universal priority score. Use F1, F2, and so on as local finding IDs. Explain ordering with concrete evidence. Provide a short, testable next check for each finding. Say when recovery was not observed rather than claiming it was impossible. Keep titles and summaries concise and specific. The overall summary must be grounded in participant reviews and observations, including successful outcomes and evidence limits.`;
61
65
  function instructions(config) {
@@ -213,9 +217,10 @@ export function preferLargerAnalysisOutput(input, config) {
213
217
  const expanded = { ...config, maxOutputTokens: MAX_ANALYSIS_OUTPUT_TOKENS };
214
218
  return estimateAnalysisAdmission(input, expanded).allowed ? expanded : config;
215
219
  }
216
- /** Scrub only generated prose. Source evidence, provenance and integrity hashes remain exact. */
220
+ /** Scrub only generated prose. Source evidence, provenance and integrity hashes remain exact. A
221
+ * known value is found as written and in its encoded forms. */
217
222
  function scrubGeneratedNarrative(result) {
218
- const scrub = scrubTransientCommsText;
223
+ const scrub = transientCommsKnownValueScrub();
219
224
  const observation = (value) => ({
220
225
  ...value,
221
226
  claim: scrub(value.claim),
@@ -239,6 +244,13 @@ function scrubGeneratedNarrative(result) {
239
244
  ...value.exposedStreamIds,
240
245
  ...value.observations.flatMap((item) => [item.basis, ...item.evidenceIds]),
241
246
  ]),
247
+ ...(result.designFindings ?? []).flatMap((value) => [
248
+ value.id,
249
+ value.severity,
250
+ value.confidence,
251
+ ...value.seenByStreamIds,
252
+ ...value.evidenceIds,
253
+ ]),
242
254
  ...(result.concernReviews ?? []).flatMap((value) => [
243
255
  value.basis,
244
256
  value.disposition,
@@ -263,12 +275,26 @@ function scrubGeneratedNarrative(result) {
263
275
  findings: result.findings.map((value) => ({
264
276
  ...value,
265
277
  title: scrub(value.title),
278
+ ...(value.headline === undefined ? {} : { headline: scrub(value.headline) }),
279
+ ...(value.experience === undefined ? {} : { experience: scrub(value.experience) }),
266
280
  summary: scrub(value.summary),
267
281
  exposureReason: scrub(value.exposureReason),
268
282
  nextStep: scrub(value.nextStep),
269
283
  priorityReason: scrub(value.priorityReason),
270
284
  observations: value.observations.map(observation),
271
285
  })),
286
+ ...(result.designFindings === undefined
287
+ ? {}
288
+ : {
289
+ designFindings: result.designFindings.map((value) => ({
290
+ ...value,
291
+ headline: scrub(value.headline),
292
+ screen: scrub(value.screen),
293
+ notice: scrub(value.notice),
294
+ whyItMatters: scrub(value.whyItMatters),
295
+ suggestion: scrub(value.suggestion),
296
+ })),
297
+ }),
272
298
  ...(result.concernReviews === undefined
273
299
  ? {}
274
300
  : {
@@ -281,6 +307,10 @@ function scrubGeneratedNarrative(result) {
281
307
  }
282
308
  const VALIDATION_FAILURES = Object.freeze({
283
309
  ANALYSIS_RESULT_SCHEMA_INVALID: "analysis_validation_failed_schema_invalid",
310
+ ANALYSIS_DESIGN_FINDING_ID_DUPLICATE: "analysis_validation_failed_design_finding_id_duplicate",
311
+ ANALYSIS_DESIGN_REFERENCE_INVALID: "analysis_validation_failed_design_reference_invalid",
312
+ ANALYSIS_DESIGN_WITHOUT_CAPTURE: "analysis_validation_failed_design_without_capture",
313
+ ANALYSIS_DESIGN_MEMBERSHIP_INVALID: "analysis_validation_failed_design_membership_invalid",
284
314
  ANALYSIS_INPUT_DUPLICATES: "analysis_validation_failed_input_duplicates",
285
315
  ANALYSIS_PARTICIPANT_COVERAGE_INVALID: "analysis_validation_failed_participant_coverage_invalid",
286
316
  ANALYSIS_PARTICIPANT_REFERENCE_INVALID: "analysis_validation_failed_participant_reference_invalid",
@@ -5,6 +5,7 @@ import { lstat, mkdir, realpath, rmdir } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { renderObserver } from "../observer/render.js";
7
7
  import { containsSensitive } from "../evidence/redaction.js";
8
+ import { transientCommsKnownValueScrub } from "../run/transient-comms-secrets.js";
8
9
  import { verifyRunPrepared } from "../verify/verify.js";
9
10
  import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
10
11
  import { physicalCwdOf, runIdOf, resolvePhysicalCwd, validatePreparedRunRootIdentity, } from "../run/paths.js";
@@ -397,7 +398,12 @@ export async function correctAnalysis(cwd, run, options) {
397
398
  const finding = analysis?.result?.findings.find((item) => item.id === options.findingId);
398
399
  if (loaded.state !== "ready" || !analysis || !finding)
399
400
  throw new Error("ANALYSIS_CORRECTION_SOURCE_UNAVAILABLE");
400
- if (containsSensitive(options.reason) || containsSensitive(options.replacementClaim ?? ""))
401
+ // A reviewer's claim replaces the finding's headline in every view, so it gets the same known
402
+ // value check as generated text: as written and encoded. Only a caller inside the run's secret
403
+ // scope holds those values; a separate `analyze correct` has only the pattern check.
404
+ const known = transientCommsKnownValueScrub();
405
+ const unsafe = (text) => containsSensitive(text) || known(text) !== text;
406
+ if (unsafe(options.reason) || unsafe(options.replacementClaim ?? ""))
401
407
  throw new Error("ANALYSIS_CORRECTION_TEXT_UNSAFE");
402
408
  const correction = {
403
409
  schema: ANALYSIS_CORRECTION_SCHEMA,