humanish 0.113.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 +22 -0
- package/CONTEXT.md +1 -0
- package/README.md +1 -1
- package/dist/actors/computer-use/openai-conversation.js +9 -7
- package/dist/actors/contract.d.ts +4 -2
- 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/execute.d.ts +19 -5
- package/dist/analysis/execute.js +58 -27
- 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/study-run.js +2 -1
- package/dist/cli/findings.js +4 -1
- package/dist/cli/io.d.ts +1 -0
- package/dist/cli/io.js +11 -12
- package/dist/cli/program.js +2 -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 +51 -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/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/run/contained-output.d.ts +7 -0
- package/dist/run/contained-output.js +35 -1
- 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 +9 -0
- package/dist/run/participant-caption.js +19 -0
- package/dist/study/doctor.js +1 -1
- package/dist/study/preflight-result.js +2 -1
- package/dist/study/summary.js +2 -2
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/run-bundle.md +57 -0
- package/docs/contracts/schemas.md +5 -2
- package/docs/contracts/study-analysis.md +26 -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 +5 -0
|
@@ -38,6 +38,13 @@ export declare function prepareContainedOutputDirectory(rootInput: PreparedOutpu
|
|
|
38
38
|
export declare function prepareContainedOutputDirectoryRoot(rootInput: PreparedOutputRoot, relativePath: string): Promise<PreparedSelectedOutputDirectory>;
|
|
39
39
|
export declare function prepareContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<string>;
|
|
40
40
|
export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string | Uint8Array, encoding?: BufferEncoding): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Writes a new file inside the root without ever replacing one: the bytes go to a temporary file,
|
|
43
|
+
* which link(2) then gives the final name, failing with EEXIST when that name exists, and the
|
|
44
|
+
* temporary name is removed. writeContainedOutputFile renames instead, which replaces a file that
|
|
45
|
+
* took the name in the meantime.
|
|
46
|
+
*/
|
|
47
|
+
export declare function writeNewContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
|
|
41
48
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
42
49
|
export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
|
|
43
50
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { link, lstat, mkdir, open, realpath, rename, unlink, } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { assertDirectoryIdentity, assertRegularFileOrMissing, isPathInside, prepareHumanishStorageDirectory, resolveExistingHumanishStorageDirectory, validatePreparedRunArtifactPaths, validatePreparedRunRootIdentity, } from "./paths.js";
|
|
@@ -123,6 +123,40 @@ export async function writeContainedOutputFile(rootInput, relativePath, data, en
|
|
|
123
123
|
await assertRegularFileOrMissing(filePath, "Selected output files");
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Writes a new file inside the root without ever replacing one: the bytes go to a temporary file,
|
|
128
|
+
* which link(2) then gives the final name, failing with EEXIST when that name exists, and the
|
|
129
|
+
* temporary name is removed. writeContainedOutputFile renames instead, which replaces a file that
|
|
130
|
+
* took the name in the meantime.
|
|
131
|
+
*/
|
|
132
|
+
export async function writeNewContainedOutputFile(rootInput, relativePath, data) {
|
|
133
|
+
const filePath = await prepareContainedOutputFile(rootInput, relativePath);
|
|
134
|
+
const root = await resolveOutputRoot(rootInput);
|
|
135
|
+
const parent = path.dirname(filePath);
|
|
136
|
+
const revalidate = async () => {
|
|
137
|
+
if ((await resolveOutputRoot(rootInput)) !== root) {
|
|
138
|
+
throw new Error("Output root changed after it was prepared.");
|
|
139
|
+
}
|
|
140
|
+
await assertContainedDirectoryChain(root, parent);
|
|
141
|
+
};
|
|
142
|
+
await revalidate();
|
|
143
|
+
const temporary = path.join(parent, `.humanish-write-${process.pid}-${randomUUID()}.tmp`);
|
|
144
|
+
let handle;
|
|
145
|
+
try {
|
|
146
|
+
handle = await open(temporary, "wx", 0o600);
|
|
147
|
+
await handle.writeFile(data, "utf8");
|
|
148
|
+
await handle.sync();
|
|
149
|
+
await handle.close();
|
|
150
|
+
handle = undefined;
|
|
151
|
+
await revalidate();
|
|
152
|
+
await link(temporary, filePath);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await handle?.close().catch(() => undefined);
|
|
156
|
+
await unlink(temporary).catch(() => undefined);
|
|
157
|
+
}
|
|
158
|
+
await assertRegularFileOrMissing(filePath, "Selected output files");
|
|
159
|
+
}
|
|
126
160
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
127
161
|
export async function readContainedRegularFile(rootInput, relativePath) {
|
|
128
162
|
const handle = await openContainedRegularFile(rootInput, relativePath);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
2
|
+
export declare const RUN_NOTES_DIR = "notes";
|
|
3
|
+
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
4
|
+
export declare const MAX_NOTE_FILE_BYTES: number;
|
|
5
|
+
/** The notes one run keeps. A reader reads this many and names the rest. */
|
|
6
|
+
export declare const MAX_RUN_NOTES = 500;
|
|
7
|
+
export declare const MAX_NOTE_TEXT = 2000;
|
|
8
|
+
export interface RunNote {
|
|
9
|
+
id: string;
|
|
10
|
+
/** Milliseconds from the run clock's start. */
|
|
11
|
+
atMs: number;
|
|
12
|
+
/** The participant's stream id, or null for a note on the whole run. */
|
|
13
|
+
participant: string | null;
|
|
14
|
+
/** The latest timed capture or event at or before the moment, with its participant. */
|
|
15
|
+
nearest: {
|
|
16
|
+
participant: string;
|
|
17
|
+
itemId: string;
|
|
18
|
+
} | null;
|
|
19
|
+
text: string;
|
|
20
|
+
author: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
editedAt: string | null;
|
|
23
|
+
}
|
|
24
|
+
/** A run's notes as read, in the order they were added, and why any file was skipped. */
|
|
25
|
+
export interface RunNotes {
|
|
26
|
+
runId: string;
|
|
27
|
+
notes: RunNote[];
|
|
28
|
+
skipped: string[];
|
|
29
|
+
}
|
|
30
|
+
/** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
|
|
31
|
+
export declare function newRunNoteId(createdAt: Date): string;
|
|
32
|
+
/** The note's file, relative to the run directory. */
|
|
33
|
+
export declare function runNoteFile(id: string): string;
|
|
34
|
+
/** The note as its file holds it. */
|
|
35
|
+
export declare function encodeRunNote(note: RunNote, runId: string): string;
|
|
36
|
+
/** The note in a note file's bytes, when it is a note of `runId` stored under its own `id`. */
|
|
37
|
+
export declare function decodeRunNote(bytes: Buffer, runId: string, id: string): RunNote | null;
|
|
38
|
+
/**
|
|
39
|
+
* Up to MAX_LISTED_ENTRIES names in the notes directory at `dir`, or null when there is none.
|
|
40
|
+
* Throws when `dir` is there and is not a plain directory.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listNoteEntries(dir: string): Promise<{
|
|
43
|
+
names: string[];
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
} | null>;
|
|
46
|
+
/** The note ids to read from a listing of notes/, in creation order, and what was left out. */
|
|
47
|
+
export declare function noteListing(listing: {
|
|
48
|
+
names: readonly string[];
|
|
49
|
+
truncated: boolean;
|
|
50
|
+
}): {
|
|
51
|
+
ids: string[];
|
|
52
|
+
skipped: string[];
|
|
53
|
+
};
|
|
54
|
+
/** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
|
|
55
|
+
export declare function countRunNotes(prepared: PreparedRunArtifactPaths): Promise<number>;
|
|
56
|
+
/**
|
|
57
|
+
* The run's notes. Each file is read through the bounded reader, which refuses a link, a special
|
|
58
|
+
* file or anything over MAX_NOTE_FILE_BYTES before reading past the limit; a file it refuses, or
|
|
59
|
+
* that is not a note of this run, is skipped and named in `skipped`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function readRunNotes(prepared: PreparedRunArtifactPaths): Promise<RunNotes>;
|
|
62
|
+
/** What verify's text scan finds in shared notes: a secret-shaped value, or text it cannot read. */
|
|
63
|
+
type NotesFinding = "sensitive" | "opaque";
|
|
64
|
+
/**
|
|
65
|
+
* The run's notes as a caller is about to share them, read once, with what verify's text scan
|
|
66
|
+
* finds in that set as it would be shared. The caller shares this set only, so a note added or
|
|
67
|
+
* changed after verify ran cannot go out unchecked.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readNotesForSharing(prepared: PreparedRunArtifactPaths): Promise<{
|
|
70
|
+
notes: RunNotes;
|
|
71
|
+
finding: NotesFinding | null;
|
|
72
|
+
}>;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Reviewer notes on disk: one file per note, notes/<id>.json (humanish.run-note.v1) in the run
|
|
2
|
+
// directory. A note is written once, to a temporary file that link(2) gives its name only when
|
|
3
|
+
// nothing holds that name, and is never rewritten, so a writer never reads or replaces another
|
|
4
|
+
// writer's note and no lock is needed. An id starts
|
|
5
|
+
// with its creation time, so the files sort in the order they were added, and ends in random
|
|
6
|
+
// bits. Readers list notes/ and read each file within MAX_NOTE_FILE_BYTES, skipping and naming
|
|
7
|
+
// anything that is not a readable note of the run.
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { lstat, opendir } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { scanEncodedTextCached } from "../evidence/encoded-text.js";
|
|
12
|
+
import { readBoundedFileResult } from "./evidence-files.js";
|
|
13
|
+
import { runIdOf } from "./paths.js";
|
|
14
|
+
import { isNodeError, isRecord } from "./type-guards.js";
|
|
15
|
+
const RUN_NOTE_SCHEMA = "humanish.run-note.v1";
|
|
16
|
+
export const RUN_NOTES_DIR = "notes";
|
|
17
|
+
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
18
|
+
export const MAX_NOTE_FILE_BYTES = 16 * 1024;
|
|
19
|
+
/** The notes one run keeps. A reader reads this many and names the rest. */
|
|
20
|
+
export const MAX_RUN_NOTES = 500;
|
|
21
|
+
export const MAX_NOTE_TEXT = 2000;
|
|
22
|
+
/** The entries a reader lists in notes/ before it stops. */
|
|
23
|
+
const MAX_LISTED_ENTRIES = 2 * MAX_RUN_NOTES;
|
|
24
|
+
const NOTE_ID = /^note-\d{8}t\d{9}z-[0-9a-f]{12}$/;
|
|
25
|
+
/** A temporary file of a write in progress, or of one a stopped writer left. Never a note. */
|
|
26
|
+
const WRITE_IN_PROGRESS = /^\.humanish-write-/;
|
|
27
|
+
/** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
|
|
28
|
+
export function newRunNoteId(createdAt) {
|
|
29
|
+
const stamp = createdAt.toISOString().replace(/[-:.]/g, "").toLowerCase();
|
|
30
|
+
return `note-${stamp}-${randomBytes(6).toString("hex")}`;
|
|
31
|
+
}
|
|
32
|
+
/** The note's file, relative to the run directory. */
|
|
33
|
+
export function runNoteFile(id) {
|
|
34
|
+
return `${RUN_NOTES_DIR}/${id}.json`;
|
|
35
|
+
}
|
|
36
|
+
/** The note as its file holds it. */
|
|
37
|
+
export function encodeRunNote(note, runId) {
|
|
38
|
+
return `${JSON.stringify({ schema: RUN_NOTE_SCHEMA, runId, ...note }, null, 2)}\n`;
|
|
39
|
+
}
|
|
40
|
+
const text = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
|
|
41
|
+
const timestamp = (value) => text(value, 64) && Number.isFinite(Date.parse(value));
|
|
42
|
+
/** The note in a note file's bytes, when it is a note of `runId` stored under its own `id`. */
|
|
43
|
+
export function decodeRunNote(bytes, runId, id) {
|
|
44
|
+
let value;
|
|
45
|
+
try {
|
|
46
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (!isRecord(value) ||
|
|
52
|
+
value.schema !== RUN_NOTE_SCHEMA ||
|
|
53
|
+
value.runId !== runId ||
|
|
54
|
+
value.id !== id ||
|
|
55
|
+
typeof value.atMs !== "number" ||
|
|
56
|
+
!Number.isSafeInteger(value.atMs) ||
|
|
57
|
+
value.atMs < 0 ||
|
|
58
|
+
!(value.participant === null || text(value.participant, 256)) ||
|
|
59
|
+
!(value.nearest === null ||
|
|
60
|
+
(isRecord(value.nearest) &&
|
|
61
|
+
text(value.nearest.participant, 256) &&
|
|
62
|
+
text(value.nearest.itemId, 256))) ||
|
|
63
|
+
!text(value.text, MAX_NOTE_TEXT) ||
|
|
64
|
+
!text(value.author, 80) ||
|
|
65
|
+
!timestamp(value.createdAt) ||
|
|
66
|
+
!(value.editedAt === null || timestamp(value.editedAt)))
|
|
67
|
+
return null;
|
|
68
|
+
const nearest = isRecord(value.nearest) ? value.nearest : null;
|
|
69
|
+
return {
|
|
70
|
+
id,
|
|
71
|
+
atMs: value.atMs,
|
|
72
|
+
participant: value.participant,
|
|
73
|
+
nearest: nearest === null
|
|
74
|
+
? null
|
|
75
|
+
: { participant: String(nearest.participant), itemId: String(nearest.itemId) },
|
|
76
|
+
text: value.text,
|
|
77
|
+
author: value.author,
|
|
78
|
+
createdAt: value.createdAt,
|
|
79
|
+
editedAt: value.editedAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Up to MAX_LISTED_ENTRIES names in the notes directory at `dir`, or null when there is none.
|
|
84
|
+
* Throws when `dir` is there and is not a plain directory.
|
|
85
|
+
*/
|
|
86
|
+
export async function listNoteEntries(dir) {
|
|
87
|
+
try {
|
|
88
|
+
const stats = await lstat(dir);
|
|
89
|
+
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
90
|
+
throw new Error("The notes entry is not a directory.");
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
94
|
+
return null;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const names = [];
|
|
98
|
+
const listing = await opendir(dir);
|
|
99
|
+
try {
|
|
100
|
+
for await (const entry of listing) {
|
|
101
|
+
if (names.length === MAX_LISTED_ENTRIES)
|
|
102
|
+
return { names, truncated: true };
|
|
103
|
+
names.push(entry.name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await listing.close().catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
return { names, truncated: false };
|
|
110
|
+
}
|
|
111
|
+
/** The note ids to read from a listing of notes/, in creation order, and what was left out. */
|
|
112
|
+
export function noteListing(listing) {
|
|
113
|
+
const ids = [];
|
|
114
|
+
const skipped = [];
|
|
115
|
+
for (const name of listing.names) {
|
|
116
|
+
if (WRITE_IN_PROGRESS.test(name))
|
|
117
|
+
continue;
|
|
118
|
+
const id = name.endsWith(".json") ? name.slice(0, -".json".length) : "";
|
|
119
|
+
if (NOTE_ID.test(id))
|
|
120
|
+
ids.push(id);
|
|
121
|
+
else
|
|
122
|
+
skipped.push(`${RUN_NOTES_DIR}/${name} is not a note file, so it was skipped.`);
|
|
123
|
+
}
|
|
124
|
+
ids.sort();
|
|
125
|
+
if (listing.truncated)
|
|
126
|
+
skipped.push(`${RUN_NOTES_DIR}/ holds more than ${MAX_LISTED_ENTRIES} entries; only the first ${MAX_LISTED_ENTRIES} were listed.`);
|
|
127
|
+
if (ids.length > MAX_RUN_NOTES) {
|
|
128
|
+
const left = ids.length - MAX_RUN_NOTES;
|
|
129
|
+
skipped.push(`${left} note${left === 1 ? "" : "s"} after the first ${MAX_RUN_NOTES} ${left === 1 ? "was" : "were"} not read.`);
|
|
130
|
+
ids.length = MAX_RUN_NOTES;
|
|
131
|
+
}
|
|
132
|
+
return { ids, skipped };
|
|
133
|
+
}
|
|
134
|
+
/** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
|
|
135
|
+
export async function countRunNotes(prepared) {
|
|
136
|
+
const listing = await listNoteEntries(path.join(prepared.physicalRunRoot, RUN_NOTES_DIR));
|
|
137
|
+
return listing === null ? 0 : noteListing(listing).ids.length + (listing.truncated ? 1 : 0);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The run's notes. Each file is read through the bounded reader, which refuses a link, a special
|
|
141
|
+
* file or anything over MAX_NOTE_FILE_BYTES before reading past the limit; a file it refuses, or
|
|
142
|
+
* that is not a note of this run, is skipped and named in `skipped`.
|
|
143
|
+
*/
|
|
144
|
+
export async function readRunNotes(prepared) {
|
|
145
|
+
const runId = runIdOf(prepared);
|
|
146
|
+
let listing;
|
|
147
|
+
try {
|
|
148
|
+
listing = await listNoteEntries(path.join(prepared.physicalRunRoot, RUN_NOTES_DIR));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return {
|
|
152
|
+
runId,
|
|
153
|
+
notes: [],
|
|
154
|
+
skipped: [`${RUN_NOTES_DIR} in run ${runId} is not a plain folder, so no notes were read.`],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (listing === null)
|
|
158
|
+
return { runId, notes: [], skipped: [] };
|
|
159
|
+
const { ids, skipped } = noteListing(listing);
|
|
160
|
+
const notes = [];
|
|
161
|
+
for (const id of ids) {
|
|
162
|
+
const read = await readBoundedFileResult(prepared, runNoteFile(id), MAX_NOTE_FILE_BYTES);
|
|
163
|
+
const note = read.state === "read" ? decodeRunNote(read.bytes, runId, id) : null;
|
|
164
|
+
if (note !== null)
|
|
165
|
+
notes.push(note);
|
|
166
|
+
else
|
|
167
|
+
skipped.push(read.state === "limit"
|
|
168
|
+
? `${runNoteFile(id)} is over ${MAX_NOTE_FILE_BYTES / 1024} KiB, so it was skipped.`
|
|
169
|
+
: `${runNoteFile(id)} is not a readable note of run ${runId}, so it was skipped.`);
|
|
170
|
+
}
|
|
171
|
+
return { runId, notes, skipped };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The run's notes as a caller is about to share them, read once, with what verify's text scan
|
|
175
|
+
* finds in that set as it would be shared. The caller shares this set only, so a note added or
|
|
176
|
+
* changed after verify ran cannot go out unchecked.
|
|
177
|
+
*/
|
|
178
|
+
export async function readNotesForSharing(prepared) {
|
|
179
|
+
const notes = await readRunNotes(prepared);
|
|
180
|
+
if (notes.notes.length === 0)
|
|
181
|
+
return { notes, finding: null };
|
|
182
|
+
const scan = scanEncodedTextCached(JSON.stringify(notes.notes));
|
|
183
|
+
return { notes, finding: scan.sensitive ? "sensitive" : scan.opaque ? "opaque" : null };
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/study/doctor.js
CHANGED
|
@@ -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
|
|
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 } : {}),
|
package/dist/study/summary.js
CHANGED
|
@@ -15,7 +15,7 @@ import { requiredKeys, requiredSubjectEnv } from "./requirements.js";
|
|
|
15
15
|
import { automaticAnalysisBudget } 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)
|
package/docs/contracts/errors.md
CHANGED
|
@@ -46,8 +46,8 @@ Counts are the distinct quoted codes in `src/` on 2026-10-03.
|
|
|
46
46
|
The lowercase families are values recorded inside artifacts and receipts, and an artifact keeps
|
|
47
47
|
the code it was written with. Automatic analysis reads two of them back:
|
|
48
48
|
`analysis_admission_estimate_exceeded` becomes the reason `AUTOMATIC_ANALYSIS_ADMISSION_EXCEEDED`,
|
|
49
|
-
and `analysis_budget_exceeded`
|
|
50
|
-
`humanish analyze --max-cost
|
|
49
|
+
and `analysis_budget_exceeded` records the expected cost, the worst case and the cap on the job,
|
|
50
|
+
and prints them with the command that reruns it, `humanish analyze --run <id> --max-cost <n>`. Validation also accepts a partial artifact whose error is
|
|
51
51
|
`analysis_admission_estimate_exceeded`.
|
|
52
52
|
|
|
53
53
|
Share-safety reasons in `humanish verify` (`VERIFY_FAILED`, `RAW_SCREENSHOTS` and the rest) are
|