humanish 0.113.0 → 0.115.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 (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +1 -1
  4. package/dist/actors/closing-report-limits.d.ts +11 -0
  5. package/dist/actors/closing-report-limits.js +13 -0
  6. package/dist/actors/closing-report.d.ts +68 -0
  7. package/dist/actors/closing-report.js +55 -0
  8. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  9. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  10. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  11. package/dist/actors/computer-use/loop/debrief.js +10 -46
  12. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  13. package/dist/actors/computer-use/loop/impressions.js +6 -1
  14. package/dist/actors/computer-use/loop.d.ts +0 -1
  15. package/dist/actors/computer-use/loop.js +3 -3
  16. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  17. package/dist/actors/computer-use/openai-conversation.js +12 -36
  18. package/dist/actors/computer-use/openai-provider.js +11 -5
  19. package/dist/actors/contract.d.ts +6 -22
  20. package/dist/actors/contract.js +0 -13
  21. package/dist/analysis/admission.d.ts +47 -0
  22. package/dist/analysis/admission.js +65 -0
  23. package/dist/analysis/automatic-completion.d.ts +0 -6
  24. package/dist/analysis/automatic-completion.js +3 -3
  25. package/dist/analysis/automatic-config.d.ts +10 -2
  26. package/dist/analysis/automatic-config.js +29 -14
  27. package/dist/analysis/automatic.js +7 -0
  28. package/dist/analysis/execute.d.ts +24 -5
  29. package/dist/analysis/execute.js +61 -27
  30. package/dist/analysis/finding-lead.d.ts +27 -0
  31. package/dist/analysis/finding-lead.js +23 -0
  32. package/dist/analysis/image-tokens.d.ts +2 -0
  33. package/dist/analysis/image-tokens.js +1 -1
  34. package/dist/analysis/job.d.ts +9 -1
  35. package/dist/analysis/job.js +11 -2
  36. package/dist/analysis/service.js +8 -3
  37. package/dist/cli/commands/analyze.js +7 -2
  38. package/dist/cli/commands/notes.d.ts +3 -0
  39. package/dist/cli/commands/notes.js +145 -0
  40. package/dist/cli/commands/observe.js +1 -1
  41. package/dist/cli/commands/study-run.js +2 -1
  42. package/dist/cli/findings.js +11 -30
  43. package/dist/cli/io.d.ts +1 -0
  44. package/dist/cli/io.js +8 -12
  45. package/dist/cli/program.js +2 -0
  46. package/dist/cli/telemetry.js +10 -22
  47. package/dist/cli/update-check.js +6 -18
  48. package/dist/cli/user-config.d.ts +11 -0
  49. package/dist/cli/user-config.js +25 -0
  50. package/dist/comms/email-catch.js +7 -31
  51. package/dist/evidence/encoded-text.d.ts +17 -3
  52. package/dist/evidence/encoded-text.js +121 -46
  53. package/dist/evidence/redaction.d.ts +11 -0
  54. package/dist/evidence/redaction.js +21 -10
  55. package/dist/evidence/secret-scrub.d.ts +14 -1
  56. package/dist/evidence/secret-scrub.js +125 -28
  57. package/dist/feedback/draft.d.ts +19 -0
  58. package/dist/feedback/draft.js +62 -15
  59. package/dist/feedback/export-bundle.js +3 -3
  60. package/dist/feedback/export.js +50 -20
  61. package/dist/feedback/feedback.js +29 -2
  62. package/dist/keys/key-resolution.js +12 -17
  63. package/dist/observer/artifact.d.ts +5 -0
  64. package/dist/observer/artifact.js +18 -2
  65. package/dist/observer/http.d.ts +7 -0
  66. package/dist/observer/http.js +19 -1
  67. package/dist/observer/notes-route.d.ts +20 -0
  68. package/dist/observer/notes-route.js +120 -0
  69. package/dist/observer/pinned-files.d.ts +8 -1
  70. package/dist/observer/pinned-files.js +17 -3
  71. package/dist/observer/render.js +39 -14
  72. package/dist/observer/run-routes.d.ts +8 -2
  73. package/dist/observer/run-routes.js +32 -6
  74. package/dist/observer/serve.d.ts +3 -0
  75. package/dist/observer/serve.js +29 -3
  76. package/dist/observer-app.html +7 -6
  77. package/dist/run/contained-output.d.ts +8 -0
  78. package/dist/run/contained-output.js +27 -0
  79. package/dist/run/evidence-files.d.ts +7 -0
  80. package/dist/run/evidence-files.js +24 -14
  81. package/dist/run/note-files.d.ts +54 -0
  82. package/dist/run/note-files.js +212 -0
  83. package/dist/run/note-shape.d.ts +35 -0
  84. package/dist/run/note-shape.js +20 -0
  85. package/dist/run/notes.d.ts +34 -0
  86. package/dist/run/notes.js +192 -0
  87. package/dist/run/participant-caption.d.ts +9 -0
  88. package/dist/run/participant-caption.js +19 -0
  89. package/dist/run/participant-records.d.ts +2 -0
  90. package/dist/run/participant-records.js +17 -2
  91. package/dist/run/transient-comms-secrets.d.ts +6 -5
  92. package/dist/run/transient-comms-secrets.js +19 -24
  93. package/dist/study/doctor.js +1 -1
  94. package/dist/study/preflight-result.js +2 -1
  95. package/dist/study/summary.d.ts +4 -5
  96. package/dist/study/summary.js +6 -4
  97. package/dist/study/warnings.js +2 -1
  98. package/dist/tui-app.js +1 -1
  99. package/docs/architecture/actor-contract.md +3 -3
  100. package/docs/architecture/restricted-codex-analysis.md +1 -1
  101. package/docs/architecture/state-driven-executor.md +1 -1
  102. package/docs/contracts/errors.md +2 -2
  103. package/docs/contracts/run-bundle.md +61 -0
  104. package/docs/contracts/schemas.md +17 -14
  105. package/docs/contracts/study-analysis.md +35 -9
  106. package/docs/principles/invariants-and-defaults.md +2 -2
  107. package/docs/product/automatic-analysis.md +22 -16
  108. package/package.json +8 -7
  109. package/skills/humanish/SKILL.md +6 -2
@@ -0,0 +1,192 @@
1
+ // Reviewer notes: free text a person adds at a moment of a recorded run. Each note is its own file
2
+ // in the run directory (note-files.ts). A note's time counts from the run clock's start, the
3
+ // first timed capture or desktop video of any participant, which is the Observer's study clock.
4
+ import { cli } from "../cli/invocation.js";
5
+ import { redactText } from "../evidence/redaction.js";
6
+ import { createContainedOutputFile } from "./contained-output.js";
7
+ import { loadRunBundlePrepared } from "./locate.js";
8
+ import { countRunNotes, encodeRunNote, newRunNoteId, runNoteFile } from "./note-files.js";
9
+ import { MAX_NOTE_TEXT, MAX_RUN_NOTES } from "./note-shape.js";
10
+ import { runParticipantCaptions, streamParticipantIdOf } from "./participant-records.js";
11
+ import { physicalCwdOf, runIdOf } from "./paths.js";
12
+ import { transientCommsKnownValueScrub } from "./transient-comms-secrets.js";
13
+ import { isNodeError } from "./type-guards.js";
14
+ const DEFAULT_AUTHOR = "you";
15
+ /** New ids a note tries when the one before was taken. */
16
+ const PUBLISH_ATTEMPTS = 5;
17
+ const refuse = (code, message) => ({
18
+ ok: false,
19
+ error: { code, message },
20
+ });
21
+ /**
22
+ * The stream id a note's participant names: a stream id, or the participant's own id from the study
23
+ * (`charge-nurse`). Both kinds of id are matched, so a name that is one stream's id and another
24
+ * stream's study id is refused, naming both, as is a study id that two streams record. Otherwise
25
+ * the refusal names the run's participants by caption, with the id to type for each.
26
+ */
27
+ function participantStreamId(bundle, named) {
28
+ const captions = runParticipantCaptions(bundle);
29
+ const matching = bundle.streams.filter((stream) => stream.id === named || streamParticipantIdOf(stream) === named);
30
+ if (matching.length === 1)
31
+ return matching[0].id;
32
+ const described = (stream) => {
33
+ const participantId = streamParticipantIdOf(stream);
34
+ return `${captions.get(stream.id)} (stream id ${stream.id}${participantId === undefined ? "" : `, study id ${participantId}`})`;
35
+ };
36
+ if (matching.length > 1)
37
+ return refuse("HUMANISH_NOTE_UNKNOWN_PARTICIPANT", `${named} names ${matching.length} participants in run ${bundle.runId}: ${matching.map(described).join(" and ")}. Name the one you mean by an id only it has.`);
38
+ const known = bundle.streams.map((stream) => `${captions.get(stream.id)} (${streamParticipantIdOf(stream) ?? stream.id})`);
39
+ return refuse("HUMANISH_NOTE_UNKNOWN_PARTICIPANT", `Run ${bundle.runId} has no participant ${named}. Its participants are ${known.join(", ")}; leave the participant out for a note on the whole run.`);
40
+ }
41
+ /** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
42
+ export function formatRunTime(ms) {
43
+ const seconds = Math.max(0, Math.floor(ms / 1000));
44
+ return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
45
+ }
46
+ /** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
47
+ export function runClock(bundle) {
48
+ const moments = bundle.streams.flatMap((stream) => [
49
+ ...(captureTimes(stream) ?? []),
50
+ ...recordingMoments(stream),
51
+ ]);
52
+ if (moments.length === 0)
53
+ return null;
54
+ // A loop, since spreading a long recording's capture times into Math.min can overflow the stack.
55
+ let startMs = Infinity;
56
+ let endMs = -Infinity;
57
+ for (const moment of moments) {
58
+ startMs = Math.min(startMs, moment);
59
+ endMs = Math.max(endMs, moment);
60
+ }
61
+ return { startMs, endMs };
62
+ }
63
+ function traceItems(stream) {
64
+ return stream.actor?.items ?? stream.liveActor?.items ?? [];
65
+ }
66
+ const stamp = (item) => item.at === undefined ? Number.NaN : Date.parse(item.at);
67
+ /**
68
+ * A participant's capture times, as the Observer's study clock reads them: only when every capture
69
+ * is stamped and the stamps never go back. Otherwise the participant adds nothing to the clock.
70
+ */
71
+ function captureTimes(stream) {
72
+ const times = traceItems(stream)
73
+ .filter((item) => (item.kind === "screenshot" || item.kind === "ui_action") && item.screenshotRef)
74
+ .map(stamp);
75
+ const ordered = times.every((time, index) => Number.isFinite(time) && (index === 0 || time >= times[index - 1]));
76
+ return ordered ? times : null;
77
+ }
78
+ function recordingMoments(stream) {
79
+ const recording = stream.recording;
80
+ if (!recording)
81
+ return [];
82
+ const startMs = Date.parse(recording.startedAt);
83
+ const endMs = startMs + recording.durationMs;
84
+ return Number.isFinite(startMs) && Number.isFinite(endMs) && recording.durationMs > 0
85
+ ? [startMs, endMs]
86
+ : [];
87
+ }
88
+ /** The latest stamped trace item at or before `moment`, among the given participants. */
89
+ function nearestItem(streams, moment) {
90
+ let best = null;
91
+ for (const stream of streams)
92
+ for (const item of traceItems(stream)) {
93
+ const time = stamp(item);
94
+ if (Number.isFinite(time) && time <= moment && (best === null || time >= best.time))
95
+ best = { participant: stream.id, itemId: item.id, time };
96
+ }
97
+ return best === null ? null : { participant: best.participant, itemId: best.itemId };
98
+ }
99
+ /**
100
+ * The text as it is written: line breaks as `\n`, the run's known values and every secret-shaped
101
+ * value or local path replaced, as other run text is before it is written.
102
+ */
103
+ function scrubNoteText(text) {
104
+ let known;
105
+ try {
106
+ known = transientCommsKnownValueScrub()(text);
107
+ }
108
+ catch {
109
+ // A closed run scope refuses to scrub and has dropped its values. The note then gets the
110
+ // pattern redaction that a separate `humanish notes` process gives it.
111
+ known = text;
112
+ }
113
+ return redactText(known.replace(/\r\n?/g, "\n").trim());
114
+ }
115
+ /** Why the text cannot be a note, or null when it can. */
116
+ function noteTextProblem(text) {
117
+ if (text.length === 0)
118
+ return "A note needs some text.";
119
+ if (text.length > MAX_NOTE_TEXT)
120
+ return `A note holds at most ${MAX_NOTE_TEXT} characters; this one has ${text.length}. Shorten it and add it again.`;
121
+ if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(text))
122
+ return "A note holds plain text and line breaks; this one has a control character. Remove it and add the note again.";
123
+ return null;
124
+ }
125
+ /**
126
+ * Adds a note as a new file, notes/<id>.json, created under a name nothing holds. It reads no other
127
+ * note and replaces nothing, so notes added at the same time, from any process, are all kept.
128
+ */
129
+ export async function addRunNote(prepared, input) {
130
+ const text = scrubNoteText(input.text);
131
+ const problem = noteTextProblem(text);
132
+ if (problem)
133
+ return refuse("HUMANISH_NOTE_INVALID", problem);
134
+ let loaded;
135
+ try {
136
+ loaded = await loadRunBundlePrepared(physicalCwdOf(prepared), prepared);
137
+ }
138
+ catch (error) {
139
+ // The storage check throws on a link or a special file anywhere in the run directory, or on a
140
+ // run directory that moved.
141
+ return refuse("HUMANISH_INVALID_RUN_BUNDLE", `No note was added to run ${runIdOf(prepared)}: its directory failed humanish's storage check. ${error instanceof Error ? error.message : String(error)} Fix that in the run directory and add the note again.`);
142
+ }
143
+ if (!loaded)
144
+ return refuse("HUMANISH_INVALID_RUN_BUNDLE", `Run ${runIdOf(prepared)} has no run.json humanish can read safely, so no note was added. \`${cli(`verify --run ${runIdOf(prepared)}`)}\` says what is wrong with it.`);
145
+ const { bundle } = loaded;
146
+ const clock = runClock(bundle);
147
+ if (!clock)
148
+ return refuse("HUMANISH_NOTE_NO_CLOCK", `Run ${bundle.runId} has no captures with recorded times, so a note cannot point at a moment of it.`);
149
+ const participant = input.participant === null ? null : participantStreamId(bundle, input.participant);
150
+ if (participant !== null && typeof participant !== "string")
151
+ return participant;
152
+ // The Observer shows whole seconds, so a moment in the second the clock ends is its end.
153
+ const durationMs = clock.endMs - clock.startMs;
154
+ if (!Number.isFinite(input.atMs) ||
155
+ input.atMs < 0 ||
156
+ Math.floor(input.atMs / 1000) > Math.floor(durationMs / 1000))
157
+ return refuse("HUMANISH_NOTE_OUTSIDE_RUN", `A note on run ${bundle.runId} needs a moment from 00:00 to ${formatRunTime(durationMs)}, the time its captures cover.`);
158
+ const atMs = Math.min(Math.round(input.atMs), durationMs);
159
+ const streams = bundle.streams.filter((stream) => participant === null || stream.id === participant);
160
+ const now = new Date();
161
+ const fields = {
162
+ atMs,
163
+ participant,
164
+ nearest: nearestItem(streams, clock.startMs + atMs),
165
+ text,
166
+ author: DEFAULT_AUTHOR,
167
+ createdAt: now.toISOString(),
168
+ editedAt: null,
169
+ };
170
+ try {
171
+ // A count of the files notes/ lists; no note is read.
172
+ if ((await countRunNotes(prepared)) >= MAX_RUN_NOTES)
173
+ return refuse("HUMANISH_NOTES_FULL", `Run ${bundle.runId} already has ${MAX_RUN_NOTES} notes, the most one run keeps.`);
174
+ // The note appears only under a name nothing holds; a taken name gets a new id.
175
+ for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt += 1) {
176
+ const note = { id: newRunNoteId(now), ...fields };
177
+ try {
178
+ await createContainedOutputFile(prepared, runNoteFile(note.id), encodeRunNote(note, bundle.runId));
179
+ }
180
+ catch (error) {
181
+ if (isNodeError(error) && error.code === "EEXIST")
182
+ continue;
183
+ throw error;
184
+ }
185
+ return { ok: true, note, scrubbed: text !== input.text.replace(/\r\n?/g, "\n").trim() };
186
+ }
187
+ }
188
+ catch {
189
+ return refuse("HUMANISH_NOTES_UNREADABLE", `The notes folder in run ${bundle.runId} is not a plain folder inside the run, so no note was added. Move it out of the run directory and add the note again.`);
190
+ }
191
+ return refuse("HUMANISH_NOTE_ID_TAKEN", `Each new id humanish chose for the note was already taken in run ${bundle.runId}'s notes folder, so no note was added. Something else is writing files there; add the note again.`);
192
+ }
@@ -29,3 +29,12 @@ export interface SavedCaptionFacts {
29
29
  * recorded persona; a scripted mobile surface also identifies a phone.
30
30
  */
31
31
  export declare function savedCaption(stream: SavedCaptionFacts): string;
32
+ /** A saved stream, as much of it as `streamCaptions` reads. */
33
+ export interface CaptionedStream extends SavedCaptionFacts {
34
+ readonly id: string;
35
+ }
36
+ /**
37
+ * Each stream's caption, by stream id, as the Observer shows it: the saved caption, with the
38
+ * participant id or the stream id added where two streams would read the same.
39
+ */
40
+ export declare function streamCaptions(streams: readonly CaptionedStream[]): Map<string, string>;
@@ -58,3 +58,22 @@ export function savedCaption(stream) {
58
58
  }
59
59
  return label;
60
60
  }
61
+ /**
62
+ * Each stream's caption, by stream id, as the Observer shows it: the saved caption, with the
63
+ * participant id or the stream id added where two streams would read the same.
64
+ */
65
+ export function streamCaptions(streams) {
66
+ const repeated = (labels) => new Set(labels.filter((label, index) => labels.indexOf(label) !== index));
67
+ const saved = streams.map(savedCaption);
68
+ const savedTwice = repeated(saved);
69
+ const qualified = streams.map((stream, index) => savedTwice.has(saved[index])
70
+ ? `${saved[index]} · ${stream.participantId ?? stream.id}`
71
+ : saved[index]);
72
+ const qualifiedTwice = repeated(qualified);
73
+ return new Map(streams.map((stream, index) => [
74
+ stream.id,
75
+ qualifiedTwice.has(qualified[index])
76
+ ? `${qualified[index]} · ${stream.id}`
77
+ : qualified[index],
78
+ ]));
79
+ }
@@ -28,3 +28,5 @@ export declare function eventRecordIdOf(event: RunEvent): string | undefined;
28
28
  export declare function streamParticipantIdOf(stream: Pick<RunStream, "laneId">): string | undefined;
29
29
  /** The persona recorded for a stream, including dry runs without an actor trace. */
30
30
  export declare function recordedPersonaId(bundle: Partial<Pick<RunBundle, "simulations">>, stream: Partial<Pick<RunStream, "simId">>): string | undefined;
31
+ /** Each participant's caption, by stream id, as the Observer and the TUI name them. */
32
+ export declare function runParticipantCaptions(bundle: Pick<RunBundle, "streams" | "simulations">): Map<string, string>;
@@ -1,7 +1,9 @@
1
1
  // The run bundle's participant records, written and read. A route describes each participant (its
2
2
  // record and stream fields, its events) and these functions give them the bundle's saved shape and
3
- // field names; readers ask them for the ids a saved record carries. This module and run/bundle.ts
4
- // (its types and bundleHead) and run/streams.ts are where those contract spellings live.
3
+ // field names; readers ask them for the ids a saved record carries and the caption it shows. This
4
+ // module and run/bundle.ts (its types and bundleHead) and run/streams.ts are where those contract
5
+ // spellings live.
6
+ import { streamCaptions } from "./participant-caption.js";
5
7
  /** Ids a route chose itself, for routes whose ids are not the `sim-NNN` default. */
6
8
  export function participantIdsOf(recordId, streamId) {
7
9
  return { recordId, streamId };
@@ -67,3 +69,16 @@ export function recordedPersonaId(bundle, stream) {
67
69
  return undefined;
68
70
  return bundle.simulations.find((record) => record?.id === stream.simId)?.personaId;
69
71
  }
72
+ /** Each participant's caption, by stream id, as the Observer and the TUI name them. */
73
+ export function runParticipantCaptions(bundle) {
74
+ return streamCaptions(bundle.streams.map((stream) => {
75
+ const participantId = streamParticipantIdOf(stream);
76
+ const personaId = recordedPersonaId(bundle, stream);
77
+ return {
78
+ id: stream.id,
79
+ label: stream.label,
80
+ ...(participantId === undefined ? {} : { participantId }),
81
+ ...(personaId === undefined ? {} : { personaId }),
82
+ };
83
+ }));
84
+ }
@@ -6,10 +6,11 @@ export declare function scrubTransientCommsText(text: string): string;
6
6
  /**
7
7
  * A scrub for text a model writes: each scope value as written and in its encoded forms
8
8
  * (percent-encoded, JSON-escaped, base64, base64url, hex), and where escapes split one. Text that
9
- * holds an encoded value is returned decoded with each value replaced. Text without one keeps its
10
- * original spelling, so an exact quote that holds an escape still matches its evidence. The literal
11
- * scrub runs first, before decoding can rewrite a value that itself holds an escape, and last,
12
- * because the encoded scrub leaves text inside marker-shaped spans alone. Outside a scope it
13
- * changes nothing.
9
+ * holds a value is returned decoded with each value replaced. Text without one keeps its original
10
+ * spelling, so an exact quote that holds an escape still matches its evidence. The literal scrub
11
+ * runs first, before decoding can rewrite a value that itself holds an escape, and last. The
12
+ * result is checked last of all: if it still holds a value as written or decoded, as a value
13
+ * holding part of a marker can after the literal scrub, the whole text is replaced. Outside a
14
+ * scope it changes nothing.
14
15
  */
15
16
  export declare function transientCommsKnownValueScrub(): (text: string) => string;
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { decodeEscapes } from "../evidence/encoded-text.js";
3
- import { scrubSecretValues } from "../evidence/secret-scrub.js";
2
+ import { REDACTION_MARKERS } from "../evidence/redaction.js";
3
+ import { holdsSecretValue, scrubSecretValues } from "../evidence/secret-scrub.js";
4
4
  import { escapeRegExp } from "./text.js";
5
5
  const scopes = new AsyncLocalStorage();
6
6
  const MAX_VALUES = 8192;
@@ -66,39 +66,34 @@ export function scrubTransientCommsText(text) {
66
66
  .sort((a, b) => b.length - a.length)
67
67
  .map(escapeRegExp)
68
68
  .join("|"), "g");
69
- return text.replace(scope.pattern, "[REDACTED_SECRET]");
69
+ return text.replace(scope.pattern, REDACTION_MARKERS.secret);
70
70
  }
71
71
  catch {
72
72
  return fail(scope);
73
73
  }
74
74
  }
75
- /**
76
- * The scope's values as a scrubSecretValues scrub, which also finds them percent-encoded, escaped or
77
- * base64-encoded and returns decoded text. Outside a scope it changes nothing.
78
- */
79
- function transientCommsEncodedScrub() {
80
- const scope = scopes.getStore();
81
- if (!scope)
82
- return (text) => text;
83
- usable(scope);
84
- return scrubSecretValues([...scope.values]);
85
- }
86
- const REDACTED = "[REDACTED_SECRET]";
87
- const markers = (text) => text.split(REDACTED).length - 1;
75
+ const REDACTED = REDACTION_MARKERS.secret;
88
76
  /**
89
77
  * A scrub for text a model writes: each scope value as written and in its encoded forms
90
78
  * (percent-encoded, JSON-escaped, base64, base64url, hex), and where escapes split one. Text that
91
- * holds an encoded value is returned decoded with each value replaced. Text without one keeps its
92
- * original spelling, so an exact quote that holds an escape still matches its evidence. The literal
93
- * scrub runs first, before decoding can rewrite a value that itself holds an escape, and last,
94
- * because the encoded scrub leaves text inside marker-shaped spans alone. Outside a scope it
95
- * changes nothing.
79
+ * holds a value is returned decoded with each value replaced. Text without one keeps its original
80
+ * spelling, so an exact quote that holds an escape still matches its evidence. The literal scrub
81
+ * runs first, before decoding can rewrite a value that itself holds an escape, and last. The
82
+ * result is checked last of all: if it still holds a value as written or decoded, as a value
83
+ * holding part of a marker can after the literal scrub, the whole text is replaced. Outside a
84
+ * scope it changes nothing.
96
85
  */
97
86
  export function transientCommsKnownValueScrub() {
98
- const encoded = transientCommsEncodedScrub();
87
+ const scope = scopes.getStore();
88
+ if (!scope)
89
+ return scrubTransientCommsText;
90
+ usable(scope);
91
+ const values = [...scope.values];
92
+ const encoded = scrubSecretValues(values);
93
+ const holds = holdsSecretValue(values);
99
94
  return (text) => {
100
95
  const literal = scrubTransientCommsText(text);
101
- const found = encoded(literal);
102
- return scrubTransientCommsText(markers(found) > markers(decodeEscapes(literal)) ? found : literal);
96
+ const result = scrubTransientCommsText(holds(literal) ? encoded(literal) : literal);
97
+ return holds(result) ? REDACTED : result;
103
98
  };
104
99
  }
@@ -368,7 +368,7 @@ async function analysisCheck(analysis, args, checkAccount) {
368
368
  ok: true,
369
369
  ...(keyed ? {} : { status: "note" }),
370
370
  message: keyed
371
- ? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if its estimate is over $${analysis.maxCostUsd}; this is not a billing cap. Participant readiness is independent.`
371
+ ? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if its expected cost plus a 10% margin is over $${analysis.maxCostUsd}; this is not a billing cap. Participant readiness is independent.`
372
372
  : "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key or set review.analysis: false deliberately.",
373
373
  };
374
374
  }
@@ -2,11 +2,12 @@
2
2
  // preflight-probes.ts both finish through these, so they live below both.
3
3
  import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
4
4
  import { digestText } from "../evidence/redaction.js";
5
+ import { computerUseParticipantCount } from "./routing.js";
5
6
  /** The schema of `study check` results: whether a study is ready to run on this machine. */
6
7
  export const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
7
8
  export function finalize(ctx, args) {
8
9
  const checks = [...ctx.checks, ...(args?.check ? [args.check] : []), ...(args?.checks ?? [])];
9
- const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route);
10
+ const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config));
10
11
  return {
11
12
  schema: STUDY_CHECK_SCHEMA,
12
13
  ...(analysis ? { analysis } : {}),
@@ -1,4 +1,5 @@
1
1
  import { type LocalRuntimeStatus } from "../substrates/local/runtime.js";
2
+ import { type AutomaticAnalysisBudget } from "../analysis/automatic-config.js";
2
3
  import { type KeyResolutionDeps } from "../keys/key-resolution.js";
3
4
  export declare const STUDY_SUMMARY_SCHEMA = "humanish.study-summary.v1";
4
5
  export interface StudyCaps {
@@ -14,11 +15,9 @@ export interface StudySummary {
14
15
  message: string;
15
16
  };
16
17
  communications?: string;
17
- analysis?: {
18
- provider?: "openai" | "codex";
19
- billing?: "api-estimate" | "account-unknown";
20
- model: string;
21
- maxCostUsd: number | null;
18
+ analysis?: AutomaticAnalysisBudget & {
19
+ /** The budget line `run` and `study check` print, from formatAutomaticAnalysisBudget. */
20
+ line: string;
22
21
  };
23
22
  schema: typeof STUDY_SUMMARY_SCHEMA;
24
23
  /** The study's `id`. */
@@ -12,10 +12,10 @@ import { resolveStudyDryRun } from "./plan.js";
12
12
  import { routeOf } from "./plan.js";
13
13
  import { localCodexParticipantCheck, planCliRun } from "./doctor.js";
14
14
  import { requiredKeys, requiredSubjectEnv } from "./requirements.js";
15
- import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
15
+ import { automaticAnalysisBudget, formatAutomaticAnalysisBudget, } from "../analysis/automatic-config.js";
16
16
  import { DEFAULT_OPENAI_CU_MODEL, DEFAULT_OPENAI_CU_REASONING_EFFORT, } from "../actors/computer-use/openai-provider.js";
17
17
  import { inspectStudyManifest } from "./discover.js";
18
- import { isComputerUseComposition } from "./routing.js";
18
+ import { computerUseParticipantCount, isComputerUseComposition } from "./routing.js";
19
19
  import { probeKeySources } from "../keys/key-resolution.js";
20
20
  import { receivingRequiredKey } from "../comms/setup.js";
21
21
  import { participantList, declaredParticipantCount } from "./study-fields.js";
@@ -127,7 +127,7 @@ export async function readStudySummary(cwd, study, options = {}) {
127
127
  missingKeys = missing;
128
128
  }
129
129
  // Computed once: a test-then-use pair reads as though the two calls could differ.
130
- const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route);
130
+ const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
131
131
  const subject = subjectOf(config);
132
132
  const participants = participantsOf(inspected.config);
133
133
  const runtime = options.checkKeys === true && isLocalBrowserStudy(inspected.config)
@@ -143,7 +143,9 @@ export async function readStudySummary(cwd, study, options = {}) {
143
143
  ? await localCodexParticipantCheck({ env: options.env ?? process.env })
144
144
  : undefined;
145
145
  return {
146
- ...(analysis ? { analysis } : {}),
146
+ ...(analysis
147
+ ? { analysis: { ...analysis, line: formatAutomaticAnalysisBudget(analysis) } }
148
+ : {}),
147
149
  ...(runtime
148
150
  ? { runtime: { ok: runtime.ok, installed: runtime.installed, message: runtime.message } }
149
151
  : {}),
@@ -1,3 +1,4 @@
1
+ import { cli } from "../cli/invocation.js";
1
2
  import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
2
3
  import { declaredParticipantIds } from "./plan-participants.js";
3
4
  import { addressedRecipients } from "./parse/comms.js";
@@ -386,7 +387,7 @@ export function personaBackgroundWarnings(config, personas) {
386
387
  ? `persona ${personaId} has no readable background`
387
388
  : "no persona is assigned";
388
389
  return [
389
- `Participant ${id} has no persona background because ${reason}. Add a short, fictional background describing their experience and situation. Run humanish study show ${config.id} --json to see what the participant receives.`,
390
+ `Participant ${id} has no persona background because ${reason}. Add a short, fictional background describing their experience and situation. Run ${cli(`study show ${config.id} --json`)} to see what the participant receives.`,
390
391
  ];
391
392
  });
392
393
  }