humanish 0.112.0 → 0.114.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/CONTEXT.md +1 -0
- package/README.md +12 -1
- package/TELEMETRY.md +21 -0
- package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
- package/dist/actors/codex/restricted-participant-policy.js +20 -2
- package/dist/actors/codex/restricted-participant.d.ts +1 -1
- package/dist/actors/codex/restricted-participant.js +2 -1
- package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
- package/dist/actors/computer-use/loop/debrief.js +57 -20
- package/dist/actors/computer-use/loop/ending.d.ts +5 -2
- package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
- package/dist/actors/computer-use/loop/impressions.js +40 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -1
- package/dist/actors/computer-use/loop/session.js +2 -0
- package/dist/actors/computer-use/loop/trace.js +2 -0
- package/dist/actors/computer-use/loop/types.d.ts +8 -1
- package/dist/actors/computer-use/loop.d.ts +2 -1
- package/dist/actors/computer-use/loop.js +15 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
- package/dist/actors/computer-use/openai-conversation.js +60 -29
- package/dist/actors/computer-use/openai-provider.js +18 -12
- package/dist/actors/contract.d.ts +47 -5
- package/dist/actors/contract.js +13 -0
- package/dist/analysis/admission.d.ts +37 -0
- package/dist/analysis/admission.js +60 -0
- package/dist/analysis/automatic-completion.d.ts +0 -6
- package/dist/analysis/automatic-completion.js +3 -3
- package/dist/analysis/automatic-config.d.ts +10 -2
- package/dist/analysis/automatic-config.js +29 -14
- package/dist/analysis/automatic.js +7 -0
- package/dist/analysis/evidence-sources.d.ts +3 -1
- package/dist/analysis/evidence-sources.js +10 -1
- package/dist/analysis/evidence.js +2 -2
- package/dist/analysis/execute.d.ts +20 -6
- package/dist/analysis/execute.js +61 -28
- package/dist/analysis/image-tokens.d.ts +2 -0
- package/dist/analysis/image-tokens.js +1 -1
- package/dist/analysis/job.d.ts +9 -1
- package/dist/analysis/job.js +11 -2
- package/dist/analysis/service.js +6 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +144 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/runs.js +33 -10
- package/dist/cli/commands/setup.js +95 -6
- package/dist/cli/commands/study-format.js +7 -4
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/commands/study.js +1 -1
- package/dist/cli/commands/tui.js +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +18 -0
- package/dist/cli/findings.js +4 -1
- package/dist/cli/io.d.ts +3 -0
- package/dist/cli/io.js +11 -12
- package/dist/cli/program.js +37 -1
- package/dist/cli/secret-prompt.d.ts +5 -1
- package/dist/cli/secret-prompt.js +6 -2
- package/dist/cli/update-check-worker.d.ts +1 -0
- package/dist/cli/update-check-worker.js +5 -0
- package/dist/cli/update-check.d.ts +46 -0
- package/dist/cli/update-check.js +203 -0
- package/dist/evidence/encoded-text.d.ts +15 -2
- package/dist/evidence/encoded-text.js +68 -4
- package/dist/evidence/secret-scrub.d.ts +1 -1
- package/dist/evidence/secret-scrub.js +23 -2
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +69 -1
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/feedback/export.js +47 -3
- package/dist/feedback/feedback.js +42 -2
- package/dist/keys/key-resolution.js +2 -0
- package/dist/keys/key-status.d.ts +37 -0
- package/dist/keys/key-status.js +68 -0
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/notes-route.d.ts +21 -0
- package/dist/observer/notes-route.js +140 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +32 -3
- package/dist/observer/render.js +38 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +47 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +28 -3
- package/dist/observer-app.html +7 -6
- package/dist/routes/computer-use/local-vm.js +9 -2
- package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
- package/dist/routes/computer-use/participant-execution.js +9 -1
- package/dist/routes/computer-use/rerun-selection.js +23 -21
- package/dist/routes/computer-use/result.js +3 -2
- package/dist/routes/computer-use/self-report.js +7 -1
- package/dist/routes/computer-use/setup.js +14 -0
- package/dist/routes/computer-use/types.d.ts +4 -1
- package/dist/routes/shared-world/bundle-records.js +8 -7
- package/dist/routes/shared-world/bundle.js +1 -1
- package/dist/run/contained-output.d.ts +7 -0
- package/dist/run/contained-output.js +35 -1
- package/dist/run/detail.js +4 -4
- package/dist/run/note-files.d.ts +73 -0
- package/dist/run/note-files.js +184 -0
- package/dist/run/notes.d.ts +37 -0
- package/dist/run/notes.js +198 -0
- package/dist/run/participant-caption.d.ts +11 -1
- package/dist/run/participant-caption.js +30 -1
- package/dist/run/participant-records.d.ts +3 -1
- package/dist/run/participant-records.js +6 -0
- package/dist/run-study.js +17 -1
- package/dist/study/discover.js +6 -1
- package/dist/study/doctor.d.ts +2 -0
- package/dist/study/doctor.js +46 -8
- package/dist/study/init-templates.js +10 -5
- package/dist/study/plan.js +3 -2
- package/dist/study/preflight-result.d.ts +1 -0
- package/dist/study/preflight-result.js +3 -2
- package/dist/study/preflight.d.ts +2 -0
- package/dist/study/preflight.js +54 -14
- package/dist/study/summary.js +2 -2
- package/dist/study/warnings.d.ts +5 -0
- package/dist/study/warnings.js +79 -2
- package/dist/substrates/local/capacity.d.ts +75 -0
- package/dist/substrates/local/capacity.js +143 -0
- package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
- package/dist/substrates/local/firecracker-desktop.js +34 -3
- package/dist/substrates/local/runtime-host.d.ts +16 -3
- package/dist/substrates/local/runtime-host.js +49 -4
- package/dist/substrates/local/runtime.d.ts +10 -0
- package/dist/substrates/local/runtime.js +44 -5
- package/dist/tui-app.js +58 -58
- package/docs/architecture/actor-contract.md +20 -6
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/feedback.md +8 -1
- package/docs/contracts/run-bundle.md +57 -0
- package/docs/contracts/schemas.md +62 -36
- package/docs/contracts/study-analysis.md +41 -8
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +2 -2
- package/skills/humanish/SKILL.md +26 -10
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RunBundle } from "./bundle.js";
|
|
2
|
+
import { type RunNote } from "./note-files.js";
|
|
3
|
+
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
4
|
+
export { MAX_NOTE_TEXT, readNotesForSharing, readRunNotes, type RunNote, type RunNotes, } from "./note-files.js";
|
|
5
|
+
export interface RunNoteInput {
|
|
6
|
+
atMs: number;
|
|
7
|
+
participant: string | null;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
export type RunNoteErrorCode = "HUMANISH_INVALID_RUN_BUNDLE" | "HUMANISH_NOTE_INVALID" | "HUMANISH_NOTE_NO_CLOCK" | "HUMANISH_NOTE_UNKNOWN_PARTICIPANT" | "HUMANISH_NOTE_OUTSIDE_RUN" | "HUMANISH_NOTES_UNREADABLE" | "HUMANISH_NOTES_FULL" | "HUMANISH_NOTE_ID_TAKEN";
|
|
11
|
+
export type AddRunNoteResult =
|
|
12
|
+
/** `scrubbed`: redaction replaced part of the text before it was written. */
|
|
13
|
+
{
|
|
14
|
+
ok: true;
|
|
15
|
+
note: RunNote;
|
|
16
|
+
scrubbed: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
error: {
|
|
20
|
+
code: RunNoteErrorCode;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** Each participant's caption, by stream id, as the Observer and the TUI name them. */
|
|
25
|
+
export declare function runParticipantCaptions(bundle: RunBundle): Map<string, string>;
|
|
26
|
+
/** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
|
|
27
|
+
export declare function formatRunTime(ms: number): string;
|
|
28
|
+
/** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
|
|
29
|
+
export declare function runClock(bundle: RunBundle): {
|
|
30
|
+
startMs: number;
|
|
31
|
+
endMs: number;
|
|
32
|
+
} | null;
|
|
33
|
+
/**
|
|
34
|
+
* Adds a note as a new file, notes/<id>.json, published under a name nothing holds. It reads no
|
|
35
|
+
* other note and replaces nothing, so notes added at the same time, from any process, are all kept.
|
|
36
|
+
*/
|
|
37
|
+
export declare function addRunNote(prepared: PreparedRunArtifactPaths, input: RunNoteInput): Promise<AddRunNoteResult>;
|
|
@@ -0,0 +1,198 @@
|
|
|
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 { redactText } from "../evidence/redaction.js";
|
|
5
|
+
import { writeNewContainedOutputFile } from "./contained-output.js";
|
|
6
|
+
import { loadRunBundlePrepared } from "./locate.js";
|
|
7
|
+
import { countRunNotes, encodeRunNote, MAX_NOTE_TEXT, MAX_RUN_NOTES, newRunNoteId, runNoteFile, } from "./note-files.js";
|
|
8
|
+
import { streamCaptions } from "./participant-caption.js";
|
|
9
|
+
import { recordedPersonaId, streamParticipantIdOf } from "./participant-records.js";
|
|
10
|
+
import { physicalCwdOf, runIdOf } from "./paths.js";
|
|
11
|
+
import { transientCommsKnownValueScrub } from "./transient-comms-secrets.js";
|
|
12
|
+
import { isNodeError } from "./type-guards.js";
|
|
13
|
+
export { MAX_NOTE_TEXT, readNotesForSharing, readRunNotes, } from "./note-files.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
|
+
/** Each participant's caption, by stream id, as the Observer and the TUI name them. */
|
|
22
|
+
export function runParticipantCaptions(bundle) {
|
|
23
|
+
return streamCaptions(bundle.streams.map((stream) => {
|
|
24
|
+
const participantId = streamParticipantIdOf(stream);
|
|
25
|
+
const personaId = recordedPersonaId(bundle, stream);
|
|
26
|
+
return {
|
|
27
|
+
id: stream.id,
|
|
28
|
+
label: stream.label,
|
|
29
|
+
...(participantId === undefined ? {} : { participantId }),
|
|
30
|
+
...(personaId === undefined ? {} : { personaId }),
|
|
31
|
+
};
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The stream id a note's participant names: a stream id, or the participant's own id from the study
|
|
36
|
+
* (`charge-nurse`). Both kinds of id are matched, so a name that is one stream's id and another
|
|
37
|
+
* stream's study id is refused, naming both, as is a study id that two streams record. Otherwise
|
|
38
|
+
* the refusal names the run's participants by caption, with the id to type for each.
|
|
39
|
+
*/
|
|
40
|
+
function participantStreamId(bundle, named) {
|
|
41
|
+
const captions = runParticipantCaptions(bundle);
|
|
42
|
+
const matching = bundle.streams.filter((stream) => stream.id === named || streamParticipantIdOf(stream) === named);
|
|
43
|
+
if (matching.length === 1)
|
|
44
|
+
return matching[0].id;
|
|
45
|
+
const described = (stream) => {
|
|
46
|
+
const participantId = streamParticipantIdOf(stream);
|
|
47
|
+
return `${captions.get(stream.id)} (stream id ${stream.id}${participantId === undefined ? "" : `, study id ${participantId}`})`;
|
|
48
|
+
};
|
|
49
|
+
if (matching.length > 1)
|
|
50
|
+
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.`);
|
|
51
|
+
const known = bundle.streams.map((stream) => `${captions.get(stream.id)} (${streamParticipantIdOf(stream) ?? stream.id})`);
|
|
52
|
+
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.`);
|
|
53
|
+
}
|
|
54
|
+
/** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
|
|
55
|
+
export function formatRunTime(ms) {
|
|
56
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
57
|
+
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
|
58
|
+
}
|
|
59
|
+
/** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
|
|
60
|
+
export function runClock(bundle) {
|
|
61
|
+
const moments = bundle.streams.flatMap((stream) => [
|
|
62
|
+
...(captureTimes(stream) ?? []),
|
|
63
|
+
...recordingMoments(stream),
|
|
64
|
+
]);
|
|
65
|
+
if (moments.length === 0)
|
|
66
|
+
return null;
|
|
67
|
+
// A loop, since spreading a long recording's capture times into Math.min can overflow the stack.
|
|
68
|
+
let startMs = Infinity;
|
|
69
|
+
let endMs = -Infinity;
|
|
70
|
+
for (const moment of moments) {
|
|
71
|
+
startMs = Math.min(startMs, moment);
|
|
72
|
+
endMs = Math.max(endMs, moment);
|
|
73
|
+
}
|
|
74
|
+
return { startMs, endMs };
|
|
75
|
+
}
|
|
76
|
+
function traceItems(stream) {
|
|
77
|
+
return stream.actor?.items ?? stream.liveActor?.items ?? [];
|
|
78
|
+
}
|
|
79
|
+
const stamp = (item) => item.at === undefined ? Number.NaN : Date.parse(item.at);
|
|
80
|
+
/**
|
|
81
|
+
* A participant's capture times, as the Observer's study clock reads them: only when every capture
|
|
82
|
+
* is stamped and the stamps never go back. Otherwise the participant adds nothing to the clock.
|
|
83
|
+
*/
|
|
84
|
+
function captureTimes(stream) {
|
|
85
|
+
const times = traceItems(stream)
|
|
86
|
+
.filter((item) => (item.kind === "screenshot" || item.kind === "ui_action") && item.screenshotRef)
|
|
87
|
+
.map(stamp);
|
|
88
|
+
const ordered = times.every((time, index) => Number.isFinite(time) && (index === 0 || time >= times[index - 1]));
|
|
89
|
+
return ordered ? times : null;
|
|
90
|
+
}
|
|
91
|
+
function recordingMoments(stream) {
|
|
92
|
+
const recording = stream.recording;
|
|
93
|
+
if (!recording)
|
|
94
|
+
return [];
|
|
95
|
+
const startMs = Date.parse(recording.startedAt);
|
|
96
|
+
const endMs = startMs + recording.durationMs;
|
|
97
|
+
return Number.isFinite(startMs) && Number.isFinite(endMs) && recording.durationMs > 0
|
|
98
|
+
? [startMs, endMs]
|
|
99
|
+
: [];
|
|
100
|
+
}
|
|
101
|
+
/** The latest stamped trace item at or before `moment`, among the given participants. */
|
|
102
|
+
function nearestItem(streams, moment) {
|
|
103
|
+
let best = null;
|
|
104
|
+
for (const stream of streams)
|
|
105
|
+
for (const item of traceItems(stream)) {
|
|
106
|
+
const time = stamp(item);
|
|
107
|
+
if (Number.isFinite(time) && time <= moment && (best === null || time >= best.time))
|
|
108
|
+
best = { participant: stream.id, itemId: item.id, time };
|
|
109
|
+
}
|
|
110
|
+
return best === null ? null : { participant: best.participant, itemId: best.itemId };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The text as it is written: line breaks as `\n`, the run's known values and every secret-shaped
|
|
114
|
+
* value or local path replaced, as other run text is before it is written.
|
|
115
|
+
*/
|
|
116
|
+
function scrubNoteText(text) {
|
|
117
|
+
let known;
|
|
118
|
+
try {
|
|
119
|
+
known = transientCommsKnownValueScrub()(text);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A closed run scope refuses to scrub and has dropped its values. The note then gets the
|
|
123
|
+
// pattern redaction that a separate `humanish notes` process gives it.
|
|
124
|
+
known = text;
|
|
125
|
+
}
|
|
126
|
+
return redactText(known.replace(/\r\n?/g, "\n").trim());
|
|
127
|
+
}
|
|
128
|
+
/** Why the text cannot be a note, or null when it can. */
|
|
129
|
+
function noteTextProblem(text) {
|
|
130
|
+
if (text.length === 0)
|
|
131
|
+
return "A note needs some text.";
|
|
132
|
+
if (text.length > MAX_NOTE_TEXT)
|
|
133
|
+
return `A note holds at most ${MAX_NOTE_TEXT} characters; this one has ${text.length}. Shorten it and add it again.`;
|
|
134
|
+
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(text))
|
|
135
|
+
return "A note holds plain text and line breaks; this one has a control character. Remove it and add the note again.";
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Adds a note as a new file, notes/<id>.json, published under a name nothing holds. It reads no
|
|
140
|
+
* other note and replaces nothing, so notes added at the same time, from any process, are all kept.
|
|
141
|
+
*/
|
|
142
|
+
export async function addRunNote(prepared, input) {
|
|
143
|
+
const text = scrubNoteText(input.text);
|
|
144
|
+
const problem = noteTextProblem(text);
|
|
145
|
+
if (problem)
|
|
146
|
+
return refuse("HUMANISH_NOTE_INVALID", problem);
|
|
147
|
+
// Containment checks throw on a link or special file anywhere in the run directory.
|
|
148
|
+
const loaded = await loadRunBundlePrepared(physicalCwdOf(prepared), prepared).catch(() => null);
|
|
149
|
+
if (!loaded)
|
|
150
|
+
return refuse("HUMANISH_INVALID_RUN_BUNDLE", `Run ${runIdOf(prepared)} has no run.json humanish can read safely, so no note was added. \`humanish verify --run ${runIdOf(prepared)}\` says what is wrong with it.`);
|
|
151
|
+
const { bundle } = loaded;
|
|
152
|
+
const clock = runClock(bundle);
|
|
153
|
+
if (!clock)
|
|
154
|
+
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.`);
|
|
155
|
+
const participant = input.participant === null ? null : participantStreamId(bundle, input.participant);
|
|
156
|
+
if (participant !== null && typeof participant !== "string")
|
|
157
|
+
return participant;
|
|
158
|
+
// The Observer shows whole seconds, so a moment in the second the clock ends is its end.
|
|
159
|
+
const durationMs = clock.endMs - clock.startMs;
|
|
160
|
+
if (!Number.isFinite(input.atMs) ||
|
|
161
|
+
input.atMs < 0 ||
|
|
162
|
+
Math.floor(input.atMs / 1000) > Math.floor(durationMs / 1000))
|
|
163
|
+
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.`);
|
|
164
|
+
const atMs = Math.min(Math.round(input.atMs), durationMs);
|
|
165
|
+
const streams = bundle.streams.filter((stream) => participant === null || stream.id === participant);
|
|
166
|
+
const now = new Date();
|
|
167
|
+
const fields = {
|
|
168
|
+
atMs,
|
|
169
|
+
participant,
|
|
170
|
+
nearest: nearestItem(streams, clock.startMs + atMs),
|
|
171
|
+
text,
|
|
172
|
+
author: DEFAULT_AUTHOR,
|
|
173
|
+
createdAt: now.toISOString(),
|
|
174
|
+
editedAt: null,
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
// A count of the files notes/ lists; no note is read.
|
|
178
|
+
if ((await countRunNotes(prepared)) >= MAX_RUN_NOTES)
|
|
179
|
+
return refuse("HUMANISH_NOTES_FULL", `Run ${bundle.runId} already has ${MAX_RUN_NOTES} notes, the most one run keeps.`);
|
|
180
|
+
// The note appears only under a name nothing holds; a taken name gets a new id.
|
|
181
|
+
for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt += 1) {
|
|
182
|
+
const note = { id: newRunNoteId(now), ...fields };
|
|
183
|
+
try {
|
|
184
|
+
await writeNewContainedOutputFile(prepared, runNoteFile(note.id), encodeRunNote(note, bundle.runId));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (isNodeError(error) && error.code === "EEXIST")
|
|
188
|
+
continue;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, note, scrubbed: text !== input.text.replace(/\r\n?/g, "\n").trim() };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
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.`);
|
|
196
|
+
}
|
|
197
|
+
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.`);
|
|
198
|
+
}
|
|
@@ -25,6 +25,16 @@ export interface SavedCaptionFacts {
|
|
|
25
25
|
* The caption a saved stream shows: its label, which a route writes with `participantCaption`.
|
|
26
26
|
* Earlier releases wrote ids and taxonomy into the label (`lane-01 · browser`, `CUA participant
|
|
27
27
|
* <id>: <study>`, `Concurrent persona <id> (type:<t> / ...) · <study>`), so a stream saved that way
|
|
28
|
-
* is captioned again from the id it names
|
|
28
|
+
* is captioned again from the id it names. Older terminal and scripted labels use the
|
|
29
|
+
* recorded persona; a scripted mobile surface also identifies a phone.
|
|
29
30
|
*/
|
|
30
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>;
|
|
@@ -28,11 +28,13 @@ export function participantCaption(participant) {
|
|
|
28
28
|
}
|
|
29
29
|
const OLDER_SHARED_WORLD_LABEL = /^Concurrent persona (\S+)/;
|
|
30
30
|
const OLDER_COMPUTER_USE_LABEL = /^CUA |^\S+ · browser$/;
|
|
31
|
+
const OLDER_TERMINAL_OR_SCRIPTED_LABEL = /^(Terminal agent|Desktop browser surface|Mobile browser surface) · /;
|
|
31
32
|
/**
|
|
32
33
|
* The caption a saved stream shows: its label, which a route writes with `participantCaption`.
|
|
33
34
|
* Earlier releases wrote ids and taxonomy into the label (`lane-01 · browser`, `CUA participant
|
|
34
35
|
* <id>: <study>`, `Concurrent persona <id> (type:<t> / ...) · <study>`), so a stream saved that way
|
|
35
|
-
* is captioned again from the id it names
|
|
36
|
+
* is captioned again from the id it names. Older terminal and scripted labels use the
|
|
37
|
+
* recorded persona; a scripted mobile surface also identifies a phone.
|
|
36
38
|
*/
|
|
37
39
|
export function savedCaption(stream) {
|
|
38
40
|
const { label, participantId, personaId } = stream;
|
|
@@ -40,6 +42,14 @@ export function savedCaption(stream) {
|
|
|
40
42
|
if (sharedWorldId !== undefined) {
|
|
41
43
|
return participantCaption({ id: sharedWorldId, personaId: personaId ?? sharedWorldId });
|
|
42
44
|
}
|
|
45
|
+
if (OLDER_TERMINAL_OR_SCRIPTED_LABEL.test(label) && personaId !== undefined) {
|
|
46
|
+
return participantCaption({
|
|
47
|
+
personaId,
|
|
48
|
+
...(label.startsWith("Mobile browser surface · ")
|
|
49
|
+
? { device: { name: "mobile", preset: { isMobile: true } } }
|
|
50
|
+
: {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
43
53
|
if (OLDER_COMPUTER_USE_LABEL.test(label) && personaId !== undefined) {
|
|
44
54
|
return participantCaption({
|
|
45
55
|
...(participantId === undefined ? {} : { id: participantId }),
|
|
@@ -48,3 +58,22 @@ export function savedCaption(stream) {
|
|
|
48
58
|
}
|
|
49
59
|
return label;
|
|
50
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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RunEvent, RunProviderResource, RunSimulation } from "./bundle.js";
|
|
1
|
+
import type { RunBundle, RunEvent, RunProviderResource, RunSimulation } from "./bundle.js";
|
|
2
2
|
import type { RunStream } from "./streams.js";
|
|
3
3
|
/** The ids a participant's saved records carry: its record id and its stream id. */
|
|
4
4
|
export interface ParticipantIds {
|
|
@@ -26,3 +26,5 @@ export declare function recordIdOf(stream: RunStream): string;
|
|
|
26
26
|
export declare function eventRecordIdOf(event: RunEvent): string | undefined;
|
|
27
27
|
/** The participant id an adapter recorded on a saved stream (a fan-out `lane-NN` id), when it has one. */
|
|
28
28
|
export declare function streamParticipantIdOf(stream: Pick<RunStream, "laneId">): string | undefined;
|
|
29
|
+
/** The persona recorded for a stream, including dry runs without an actor trace. */
|
|
30
|
+
export declare function recordedPersonaId(bundle: Partial<Pick<RunBundle, "simulations">>, stream: Partial<Pick<RunStream, "simId">>): string | undefined;
|
|
@@ -61,3 +61,9 @@ export function eventRecordIdOf(event) {
|
|
|
61
61
|
export function streamParticipantIdOf(stream) {
|
|
62
62
|
return stream.laneId;
|
|
63
63
|
}
|
|
64
|
+
/** The persona recorded for a stream, including dry runs without an actor trace. */
|
|
65
|
+
export function recordedPersonaId(bundle, stream) {
|
|
66
|
+
if (stream.simId === undefined || !Array.isArray(bundle.simulations))
|
|
67
|
+
return undefined;
|
|
68
|
+
return bundle.simulations.find((record) => record?.id === stream.simId)?.personaId;
|
|
69
|
+
}
|
package/dist/run-study.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// which runs the route's local checks that need no scorer (keys, subject env, the local agent) and
|
|
5
5
|
// returns the route's run. prepareStudy is the same path in two steps, so the CLI can present either
|
|
6
6
|
// refusal before it loads a declared review scorer.
|
|
7
|
+
import { participantList } from "./study/study-fields.js";
|
|
8
|
+
import { personaBackgroundWarnings } from "./study/warnings.js";
|
|
9
|
+
import { resolveCommittedPersonasForCwd, studyPersonaIds } from "./study/persona-resolve.js";
|
|
7
10
|
import { computerUseInput, scriptedInput, sharedWorldInput, terminalInput, } from "./study/route-inputs.js";
|
|
8
11
|
import { planStudy, resolveStudyDryRun, routeOf } from "./study/plan.js";
|
|
9
12
|
import { normalizeRunStudyOptions, optionRefusalOutcome, removedOptionRefusal, } from "./study/run-study-options.js";
|
|
@@ -70,7 +73,9 @@ export async function prepareStudy(config, options, deps = {}) {
|
|
|
70
73
|
outcome.result.warnings.push(...normalized.warnings);
|
|
71
74
|
return { ok: false, outcome };
|
|
72
75
|
}
|
|
73
|
-
const
|
|
76
|
+
const runWarnings = [...(planned.planned.plan.warnings ?? [])];
|
|
77
|
+
const plan = { ...planned.planned.plan, warnings: runWarnings };
|
|
78
|
+
const admitted = await admitPlan(study, planning, plan, deps, normalized.emit);
|
|
74
79
|
if (!admitted.ok) {
|
|
75
80
|
await vm?.close();
|
|
76
81
|
admitted.outcome.result.warnings.push(...normalized.warnings);
|
|
@@ -92,6 +97,17 @@ export async function prepareStudy(config, options, deps = {}) {
|
|
|
92
97
|
return outcome;
|
|
93
98
|
}
|
|
94
99
|
}
|
|
100
|
+
// Admission keeps its refusal order; these warnings join the plan before its run scope opens.
|
|
101
|
+
const personas = route === "computer-use" || route === "shared-world"
|
|
102
|
+
? (await resolveCommittedPersonasForCwd(options.cwd, studyPersonaIds(study))).personas
|
|
103
|
+
: new Map();
|
|
104
|
+
const backgroundWarnings = personaBackgroundWarnings(route === "computer-use" &&
|
|
105
|
+
options.count !== undefined &&
|
|
106
|
+
participantList(study) === undefined
|
|
107
|
+
? { ...study, participants: options.count }
|
|
108
|
+
: study, personas);
|
|
109
|
+
runWarnings.splice(0, runWarnings.length, ...(planned.planned.plan.warnings ?? []), ...backgroundWarnings);
|
|
110
|
+
normalized.warnings.push(...backgroundWarnings);
|
|
95
111
|
// The route layers the scorer over the inputs it admitted, and the local study is not
|
|
96
112
|
// rebuilt for it. The admitted runSession, provider and participant desktop belong to this
|
|
97
113
|
// study, so a rebuilt one would leave the participants they start for no finally to close.
|
package/dist/study/discover.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { personaBackgroundWarnings } from "./warnings.js";
|
|
1
2
|
import { studyPersonaIds, resolveCommittedPersonasForCwd } from "./persona-resolve.js";
|
|
2
3
|
import { personaBrief, PersonaConfigError } from "./persona.js";
|
|
3
4
|
import { constants } from "node:fs";
|
|
@@ -193,7 +194,11 @@ export async function inspectStudyManifest(cwd, study) {
|
|
|
193
194
|
config: resolved.config,
|
|
194
195
|
origin: resolved.origin,
|
|
195
196
|
path: resolved.path,
|
|
196
|
-
warnings: [
|
|
197
|
+
warnings: [
|
|
198
|
+
...resolved.warnings,
|
|
199
|
+
...personaResolution.warnings,
|
|
200
|
+
...personaBackgroundWarnings(resolved.config, personaResolution.personas),
|
|
201
|
+
],
|
|
197
202
|
};
|
|
198
203
|
}
|
|
199
204
|
function parseResolvedStudy(args) {
|
package/dist/study/doctor.d.ts
CHANGED
|
@@ -65,6 +65,8 @@ export declare function studySetupChecks(args: StudySetupCheckArgs): Promise<{
|
|
|
65
65
|
keys: string[];
|
|
66
66
|
reads?: ReadonlySet<string>;
|
|
67
67
|
checks: Check[];
|
|
68
|
+
/** The step to take first, which doctor prints before its rows. */
|
|
69
|
+
next?: string;
|
|
68
70
|
}>;
|
|
69
71
|
/**
|
|
70
72
|
* The ids of the project's labs that need each provider key for a live run, from the same plan
|
package/dist/study/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
|
|
3
3
|
import { localRuntimeStatus } from "../substrates/local/runtime.js";
|
|
4
|
+
import { capacityShortfall, describeCapacity, } from "../substrates/local/capacity.js";
|
|
4
5
|
import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
|
|
5
6
|
import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
|
|
6
7
|
import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
|
|
@@ -178,8 +179,11 @@ export async function studySetupChecks(args) {
|
|
|
178
179
|
// One account check, shared by the local participant row and the Codex analysis row.
|
|
179
180
|
let accountReadiness;
|
|
180
181
|
const checkAccount = () => (accountReadiness ??= (args.codexAnalysisReadiness ?? codexAccountReadiness)(args.env).catch(() => ({ ready: false, errorCode: "codex_unavailable" })));
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
const plan = planned.planned.plan;
|
|
183
|
+
const desktopsAtOnce = plan.route === "computer-use" ? plan.concurrency : undefined;
|
|
184
|
+
const localChecks = local ? await localBrowserChecks(config, args, desktopsAtOnce) : undefined;
|
|
185
|
+
if (localChecks !== undefined)
|
|
186
|
+
checks.push(...localChecks.checks);
|
|
183
187
|
if (config.comms?.email?.kind === "real")
|
|
184
188
|
checks.push(await realEmailCheck(config.comms.email.connection, keys, args));
|
|
185
189
|
checks.push(...(await participantChecks(config, route, keys, local, args, checkAccount)));
|
|
@@ -193,10 +197,19 @@ export async function studySetupChecks(args) {
|
|
|
193
197
|
const reads = config.review?.scorer === undefined
|
|
194
198
|
? new Set([...keyNamesOf(planned.planned.plan), ...keys])
|
|
195
199
|
: undefined;
|
|
196
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
desktop,
|
|
202
|
+
keys,
|
|
203
|
+
...(reads === undefined ? {} : { reads }),
|
|
204
|
+
checks,
|
|
205
|
+
...(localChecks?.next === undefined ? {} : { next: localChecks.next }),
|
|
206
|
+
};
|
|
197
207
|
}
|
|
198
|
-
/**
|
|
199
|
-
|
|
208
|
+
/**
|
|
209
|
+
* A local browser study's runtime, how many of its desktops fit and, with an external catch, its
|
|
210
|
+
* captured inbox. `next` is the runtime's message when it is not ready, which doctor leads with.
|
|
211
|
+
*/
|
|
212
|
+
async function localBrowserChecks(config, args, desktopsAtOnce) {
|
|
200
213
|
const checks = [];
|
|
201
214
|
const runtime = await (args.localRuntimeReadiness ??
|
|
202
215
|
(() => localRuntimeStatus({
|
|
@@ -204,7 +217,14 @@ async function localBrowserChecks(config, args) {
|
|
|
204
217
|
media: config.execution?.desktop?.media !== undefined ||
|
|
205
218
|
config.execution?.desktop?.recording !== undefined,
|
|
206
219
|
})))();
|
|
207
|
-
checks.push({
|
|
220
|
+
checks.push({
|
|
221
|
+
name: "local browser runtime",
|
|
222
|
+
ok: runtime.ok,
|
|
223
|
+
message: runtime.message,
|
|
224
|
+
...(runtime.ok && !runtime.installed ? { status: "note" } : {}),
|
|
225
|
+
});
|
|
226
|
+
if (runtime.capacity !== undefined && desktopsAtOnce !== undefined)
|
|
227
|
+
checks.push(capacityCheck(runtime.capacity, desktopsAtOnce));
|
|
208
228
|
const email = config.comms?.email;
|
|
209
229
|
if (email?.kind === "fake" && email.external) {
|
|
210
230
|
const healthy = await externalCatchHealthy(email.external, { timeoutMs: 5000 });
|
|
@@ -216,7 +236,25 @@ async function localBrowserChecks(config, args) {
|
|
|
216
236
|
: `Captured inbox is unavailable or outdated. Start or upgrade and restart ${cli("comms catch")}, then check comms.email.external.catchBaseUrl (and inboxBaseUrl if set). No participant was allocated.`,
|
|
217
237
|
});
|
|
218
238
|
}
|
|
219
|
-
return
|
|
239
|
+
return {
|
|
240
|
+
checks,
|
|
241
|
+
...(runtime.ok && runtime.installed ? {} : { next: runtime.message }),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** Whether the desktops the study runs at once fit the VM or this machine, and why. */
|
|
245
|
+
function capacityCheck(capacity, desktopsAtOnce) {
|
|
246
|
+
const shortfall = capacityShortfall(capacity, desktopsAtOnce);
|
|
247
|
+
const name = "local desktop capacity";
|
|
248
|
+
if (shortfall === undefined)
|
|
249
|
+
return {
|
|
250
|
+
name,
|
|
251
|
+
ok: true,
|
|
252
|
+
message: `${describeCapacity(capacity)} This study runs ${desktopsAtOnce} at once, so it fits.`,
|
|
253
|
+
};
|
|
254
|
+
// Linux runs a study that is short of memory with a warning, so the row is advice there.
|
|
255
|
+
return "refusal" in shortfall
|
|
256
|
+
? { name, ok: false, message: shortfall.refusal }
|
|
257
|
+
: { name, ok: true, status: "note", message: shortfall.warning };
|
|
220
258
|
}
|
|
221
259
|
/** The saved real email connection. Adds the key it needs to `keys`. */
|
|
222
260
|
async function realEmailCheck(connection, keys, args) {
|
|
@@ -330,7 +368,7 @@ async function analysisCheck(analysis, args, checkAccount) {
|
|
|
330
368
|
ok: true,
|
|
331
369
|
...(keyed ? {} : { status: "note" }),
|
|
332
370
|
message: keyed
|
|
333
|
-
? `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
|
|
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.`
|
|
334
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.",
|
|
335
373
|
};
|
|
336
374
|
}
|
|
@@ -159,9 +159,11 @@ export const starterFiles = [
|
|
|
159
159
|
id: synthetic-new-user
|
|
160
160
|
name: Synthetic New User
|
|
161
161
|
summary: A privacy-safe first-time user evaluating the app with realistic but synthetic needs.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
background: >-
|
|
163
|
+
Alex keeps personal plans in a notebook and uses email and a few everyday apps without trouble.
|
|
164
|
+
They are trying an unfamiliar app to see whether it can make everyday tasks easier. They work
|
|
165
|
+
through some friction but stop when more effort no longer seems worth it, and they take the
|
|
166
|
+
straightforward path the screen offers.
|
|
165
167
|
constraints:
|
|
166
168
|
- Do not use real personal data.
|
|
167
169
|
- Do not use production accounts.
|
|
@@ -175,9 +177,12 @@ constraints:
|
|
|
175
177
|
id: skeptical-power-user
|
|
176
178
|
name: Skeptical Power User
|
|
177
179
|
summary: A privacy-safe experienced user looking for speed, reversibility, and clear proof.
|
|
180
|
+
background: >-
|
|
181
|
+
Robin manages several volunteer projects with spreadsheets and keyboard shortcuts and is at ease
|
|
182
|
+
with advanced options. They want faster tools but have lost work before and care about undoing
|
|
183
|
+
mistakes. They have little patience for repeated friction and stop when a tool keeps getting in
|
|
184
|
+
the way.
|
|
178
185
|
traits:
|
|
179
|
-
patience: low
|
|
180
|
-
technical_confidence: high
|
|
181
186
|
accessibility_needs: keyboard_first
|
|
182
187
|
constraints:
|
|
183
188
|
- Do not use real personal data.
|
package/dist/study/plan.js
CHANGED
|
@@ -12,7 +12,7 @@ import { planBase } from "./plan-base.js";
|
|
|
12
12
|
import { routeOf } from "./routing.js";
|
|
13
13
|
import { automaticAnalysisRouteReason, taskProtocolValidationReason } from "./validation.js";
|
|
14
14
|
import { declaredParticipantCount } from "./study-fields.js";
|
|
15
|
-
import { egressAllowIgnoredWarning } from "./warnings.js";
|
|
15
|
+
import { egressAllowIgnoredWarning, scriptedMissionWarnings } from "./warnings.js";
|
|
16
16
|
export { routeOf } from "./routing.js";
|
|
17
17
|
/** Resolve dry-run: explicit override wins, else the scenario mode, else the given fallback. */
|
|
18
18
|
export function resolveStudyDryRun(config, override, fallback) {
|
|
@@ -76,10 +76,11 @@ export function planStudy(config, options, deps = {}) {
|
|
|
76
76
|
// The manifest the CLI resolved enters the plan here and nowhere else; the routes read
|
|
77
77
|
// plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
|
|
78
78
|
const warning = egressAllowIgnoredWarning(study);
|
|
79
|
+
const warnings = [...(warning === undefined ? [] : [warning]), ...scriptedMissionWarnings(study)];
|
|
79
80
|
const plan = {
|
|
80
81
|
...result.plan,
|
|
81
82
|
...(options.study === undefined ? {} : { study: options.study }),
|
|
82
|
-
...(
|
|
83
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
83
84
|
};
|
|
84
85
|
return { ok: true, planned: { plan } };
|
|
85
86
|
}
|
|
@@ -3,6 +3,7 @@ import type { StudyPreflightCheck, StudyPreflightResult, PreflightContext } from
|
|
|
3
3
|
export declare const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
|
|
4
4
|
export declare function finalize(ctx: PreflightContext, args?: {
|
|
5
5
|
check?: StudyPreflightCheck;
|
|
6
|
+
checks?: StudyPreflightCheck[];
|
|
6
7
|
}): StudyPreflightResult;
|
|
7
8
|
export declare function fail(ctx: PreflightContext, code: NonNullable<StudyPreflightResult["error"]>["code"], message: string, checks: StudyPreflightCheck[]): StudyPreflightResult;
|
|
8
9
|
export declare function digest(value: string): string;
|
|
@@ -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
|
-
const checks =
|
|
9
|
-
const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route);
|
|
9
|
+
const checks = [...ctx.checks, ...(args?.check ? [args.check] : []), ...(args?.checks ?? [])];
|
|
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 } : {}),
|
|
@@ -9,6 +9,8 @@ export interface StudyPreflightCheck {
|
|
|
9
9
|
name: string;
|
|
10
10
|
ok: boolean;
|
|
11
11
|
message: string;
|
|
12
|
+
/** `false` on a row the CLI could not check; its message names the command that checks it. */
|
|
13
|
+
checked?: false;
|
|
12
14
|
}
|
|
13
15
|
export interface StudyPreflightTarget {
|
|
14
16
|
label: string;
|