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
@@ -38,6 +38,14 @@ 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
+ * Creates a new file inside the root under its final name, refusing with EEXIST when that name
43
+ * exists, so it never replaces a file. The file is opened with O_EXCL and O_NOFOLLOW, filled in one
44
+ * write and synced, so a reader may briefly see it empty or short. It never has a temporary name or
45
+ * a second link, so the run directory passes its single-link check at every moment.
46
+ * writeContainedOutputFile renames a finished file into place instead, replacing what is there.
47
+ */
48
+ export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
41
49
  /** Read one regular file only when both lexical and physical paths stay in root. */
42
50
  export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
43
51
  /**
@@ -123,6 +123,33 @@ export async function writeContainedOutputFile(rootInput, relativePath, data, en
123
123
  await assertRegularFileOrMissing(filePath, "Selected output files");
124
124
  });
125
125
  }
126
+ /**
127
+ * Creates a new file inside the root under its final name, refusing with EEXIST when that name
128
+ * exists, so it never replaces a file. The file is opened with O_EXCL and O_NOFOLLOW, filled in one
129
+ * write and synced, so a reader may briefly see it empty or short. It never has a temporary name or
130
+ * a second link, so the run directory passes its single-link check at every moment.
131
+ * writeContainedOutputFile renames a finished file into place instead, replacing what is there.
132
+ */
133
+ export async function createContainedOutputFile(rootInput, relativePath, data) {
134
+ const filePath = await prepareContainedOutputFile(rootInput, relativePath);
135
+ const root = await resolveOutputRoot(rootInput);
136
+ await assertContainedDirectoryChain(root, path.dirname(filePath));
137
+ const bytes = Buffer.from(data, "utf8");
138
+ const handle = await open(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
139
+ try {
140
+ const { bytesWritten } = await handle.write(bytes, 0, bytes.length, 0);
141
+ if (bytesWritten !== bytes.length)
142
+ throw new Error("The file was not written in full.");
143
+ await handle.sync();
144
+ }
145
+ catch (error) {
146
+ await handle.close().catch(() => undefined);
147
+ // O_EXCL created this name here, so removing it removes only this file.
148
+ await unlink(filePath).catch(() => undefined);
149
+ throw error;
150
+ }
151
+ await handle.close();
152
+ }
126
153
  /** Read one regular file only when both lexical and physical paths stay in root. */
127
154
  export async function readContainedRegularFile(rootInput, relativePath) {
128
155
  const handle = await openContainedRegularFile(rootInput, relativePath);
@@ -1,3 +1,4 @@
1
+ import { type FileHandle } from "node:fs/promises";
1
2
  import { type PreparedOutputRoot } from "./contained-output.js";
2
3
  /**
3
4
  * Analysis inputs are retained local artifacts, never URLs or caller-selected outputs. The path is
@@ -22,6 +23,12 @@ export declare function readBoundedFile(root: PreparedOutputRoot, relativePath:
22
23
  * there that could not be read. Errors other than ENOENT propagate.
23
24
  */
24
25
  export declare function pathMissing(filePath: string): Promise<boolean>;
26
+ /**
27
+ * At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
28
+ * position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
29
+ * after it was opened is never read whole.
30
+ */
31
+ export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
25
32
  export type BoundedFileResult = {
26
33
  state: "read";
27
34
  bytes: Buffer;
@@ -87,6 +87,25 @@ export async function pathMissing(filePath) {
87
87
  throw error;
88
88
  }
89
89
  }
90
+ /**
91
+ * At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
92
+ * position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
93
+ * after it was opened is never read whole.
94
+ */
95
+ export async function readOpenedAtMost(handle, maxBytes) {
96
+ const chunks = [];
97
+ let total = 0;
98
+ for (;;) {
99
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
100
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
101
+ if (bytesRead === 0)
102
+ return Buffer.concat(chunks, total);
103
+ total += bytesRead;
104
+ if (total > maxBytes)
105
+ return null;
106
+ chunks.push(chunk.subarray(0, bytesRead));
107
+ }
108
+ }
90
109
  const unavailable = { state: "unavailable" };
91
110
  /** Size refusals are distinguished only after the same contained regular-file checks. */
92
111
  export async function readBoundedFileResult(root, relativePath, maxBytes) {
@@ -133,26 +152,17 @@ export async function readBoundedFileResult(root, relativePath, maxBytes) {
133
152
  try {
134
153
  if (!sameFile(before, await handle.stat({ bigint: true })))
135
154
  return unavailable;
136
- const chunks = [];
137
- let total = 0;
138
- while (total <= maxBytes) {
139
- const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
140
- const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
141
- if (bytesRead === 0)
142
- break;
143
- total += bytesRead;
144
- if (total > maxBytes)
145
- return unavailable;
146
- chunks.push(chunk.subarray(0, bytesRead));
147
- }
148
- if (!sameFile(before, await handle.stat({ bigint: true })) || total !== Number(before.size))
155
+ const bytes = await readOpenedAtMost(handle, maxBytes);
156
+ if (bytes === null ||
157
+ !sameFile(before, await handle.stat({ bigint: true })) ||
158
+ bytes.length !== Number(before.size))
149
159
  return unavailable;
150
160
  if ((await validateRoot()) !== physicalRoot)
151
161
  return unavailable;
152
162
  await validateParents();
153
163
  if (!sameFile(before, await lstat(candidate, { bigint: true })))
154
164
  return unavailable;
155
- return { state: "read", bytes: Buffer.concat(chunks, total) };
165
+ return { state: "read", bytes };
156
166
  }
157
167
  finally {
158
168
  await handle.close();
@@ -0,0 +1,54 @@
1
+ import { type RunNote } from "./note-shape.js";
2
+ import { type PreparedRunArtifactPaths } from "./paths.js";
3
+ export declare const RUN_NOTES_DIR = "notes";
4
+ /** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
5
+ export declare const MAX_NOTE_FILE_BYTES: number;
6
+ /** A run's notes as read, in the order they were added, and why any file was skipped. */
7
+ export interface RunNotes {
8
+ runId: string;
9
+ notes: RunNote[];
10
+ skipped: string[];
11
+ }
12
+ /** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
13
+ export declare function newRunNoteId(createdAt: Date): string;
14
+ /** The note's file, relative to the run directory. */
15
+ export declare function runNoteFile(id: string): string;
16
+ /** The note as its file holds it. */
17
+ export declare function encodeRunNote(note: RunNote, runId: string): string;
18
+ /** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
19
+ export declare function countRunNotes(prepared: PreparedRunArtifactPaths): Promise<number>;
20
+ /**
21
+ * Where a run's notes are read from: the run's id, its directory, and a reader of the files under
22
+ * it that refuses a link or a special file and stops at `maxBytes`.
23
+ */
24
+ export interface RunNoteFiles {
25
+ readonly runId: string;
26
+ /** The run directory's physical path, once it is checked to be the directory bound. Throws otherwise. */
27
+ directory(): Promise<string>;
28
+ /** A file under the run directory: its bytes, "limit" when it holds more than `maxBytes`, or null. */
29
+ read(relativePath: string, maxBytes: number): Promise<Buffer | "limit" | null>;
30
+ }
31
+ /**
32
+ * The run's notes, read from its prepared paths through the bounded reader or through `from`, the
33
+ * files of a run the caller already holds (the served Observer passes its pinned root's). Each file
34
+ * is read within MAX_NOTE_FILE_BYTES; a file the reader refuses, or that is not a note of this run,
35
+ * is skipped and named in `skipped`.
36
+ */
37
+ export declare function readRunNotes(from: PreparedRunArtifactPaths | RunNoteFiles): Promise<RunNotes>;
38
+ /** What verify's text scan finds in notes about to be shared, and the share safety that gives them. */
39
+ export interface NotesSharingProblem {
40
+ status: "local_only" | "blocked";
41
+ reason: {
42
+ code: "PUBLIC_SAFETY_FINDINGS" | "UNSCANNED_ARTIFACT";
43
+ message: string;
44
+ };
45
+ }
46
+ /**
47
+ * The run's notes as a caller is about to share them, read once, with what verify's text scan
48
+ * finds in that set as it would be shared. The caller shares this set only, so a note added or
49
+ * changed after verify ran cannot go out unchecked.
50
+ */
51
+ export declare function readNotesForSharing(prepared: PreparedRunArtifactPaths): Promise<{
52
+ notes: RunNotes;
53
+ problem: NotesSharingProblem | null;
54
+ }>;
@@ -0,0 +1,212 @@
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, by creating its name only when nothing holds it, and is never
3
+ // rewritten, so a writer never reads or replaces another writer's note and no lock is needed. An
4
+ // id starts with its creation time, so the files sort in the order they were added, and ends in
5
+ // random bits. Readers list notes/ and read each file within MAX_NOTE_FILE_BYTES, skipping and
6
+ // naming anything that is not a readable note of the run, including a note still being written.
7
+ import { randomBytes } from "node:crypto";
8
+ import { lstat, opendir } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { z } from "zod";
11
+ import { scanEncodedTextCached } from "../evidence/encoded-text.js";
12
+ import { readBoundedFileResult } from "./evidence-files.js";
13
+ import { MAX_NOTE_TEXT, MAX_RUN_NOTES, NOTE_FIELD_LIMITS, NOTE_ID, } from "./note-shape.js";
14
+ import { runIdOf, validatePreparedRunRootIdentity, } from "./paths.js";
15
+ import { isNodeError } from "./type-guards.js";
16
+ const RUN_NOTE_SCHEMA = "humanish.run-note.v1";
17
+ export const RUN_NOTES_DIR = "notes";
18
+ /** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
19
+ export const MAX_NOTE_FILE_BYTES = 16 * 1024;
20
+ /** The entries a reader lists in notes/ before it stops. */
21
+ const MAX_LISTED_ENTRIES = 2 * MAX_RUN_NOTES;
22
+ /** A temporary file of the atomic writer, or one a 0.114.0 note writer left. Never a note. */
23
+ const WRITE_IN_PROGRESS = /^\.humanish-write-/;
24
+ const text = (max) => z.string().min(1).max(max);
25
+ const timestamp = text(NOTE_FIELD_LIMITS.timestamp).refine((value) => Number.isFinite(Date.parse(value)));
26
+ /** A note file. Fields a later release adds are dropped on reading, so they never hide a note. */
27
+ const runNoteFileSchema = z.object({
28
+ schema: z.literal(RUN_NOTE_SCHEMA),
29
+ runId: z.string(),
30
+ id: z.string().regex(NOTE_ID),
31
+ atMs: z.int().min(0),
32
+ participant: text(NOTE_FIELD_LIMITS.participant).nullable(),
33
+ nearest: z
34
+ .object({
35
+ participant: text(NOTE_FIELD_LIMITS.participant),
36
+ itemId: text(NOTE_FIELD_LIMITS.itemId),
37
+ })
38
+ .nullable(),
39
+ text: text(MAX_NOTE_TEXT),
40
+ author: text(NOTE_FIELD_LIMITS.author),
41
+ createdAt: timestamp,
42
+ editedAt: timestamp.nullable(),
43
+ });
44
+ /** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
45
+ export function newRunNoteId(createdAt) {
46
+ const stamp = createdAt.toISOString().replace(/[-:.]/g, "").toLowerCase();
47
+ return `note-${stamp}-${randomBytes(6).toString("hex")}`;
48
+ }
49
+ /** The note's file, relative to the run directory. */
50
+ export function runNoteFile(id) {
51
+ return `${RUN_NOTES_DIR}/${id}.json`;
52
+ }
53
+ /** The note as its file holds it. */
54
+ export function encodeRunNote(note, runId) {
55
+ return `${JSON.stringify({ schema: RUN_NOTE_SCHEMA, runId, ...note }, null, 2)}\n`;
56
+ }
57
+ /** The note in a note file's bytes, when it is a note of `runId` stored under its own `id`. */
58
+ function decodeRunNote(bytes, runId, id) {
59
+ let value;
60
+ try {
61
+ value = JSON.parse(bytes.toString("utf8"));
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ const parsed = runNoteFileSchema.safeParse(value);
67
+ if (!parsed.success || parsed.data.runId !== runId || parsed.data.id !== id)
68
+ return null;
69
+ const { schema: _schema, runId: _runId, ...note } = parsed.data;
70
+ return note;
71
+ }
72
+ /**
73
+ * Up to MAX_LISTED_ENTRIES names in the notes directory at `dir`, or null when there is none.
74
+ * Throws when `dir` is there and is not a plain directory.
75
+ */
76
+ async function listNoteEntries(dir) {
77
+ try {
78
+ const stats = await lstat(dir);
79
+ if (!stats.isDirectory() || stats.isSymbolicLink())
80
+ throw new Error("The notes entry is not a directory.");
81
+ }
82
+ catch (error) {
83
+ if (isNodeError(error) && error.code === "ENOENT")
84
+ return null;
85
+ throw error;
86
+ }
87
+ const names = [];
88
+ const listing = await opendir(dir);
89
+ try {
90
+ for await (const entry of listing) {
91
+ if (names.length === MAX_LISTED_ENTRIES)
92
+ return { names, truncated: true };
93
+ names.push(entry.name);
94
+ }
95
+ }
96
+ finally {
97
+ await listing.close().catch(() => undefined);
98
+ }
99
+ return { names, truncated: false };
100
+ }
101
+ /** The note ids to read from a listing of notes/, in creation order, and what was left out. */
102
+ function noteListing(listing) {
103
+ const ids = [];
104
+ const skipped = [];
105
+ for (const name of listing.names) {
106
+ if (WRITE_IN_PROGRESS.test(name))
107
+ continue;
108
+ const id = name.endsWith(".json") ? name.slice(0, -".json".length) : "";
109
+ if (NOTE_ID.test(id))
110
+ ids.push(id);
111
+ else
112
+ skipped.push(`${RUN_NOTES_DIR}/${name} is not a note file, so it was skipped.`);
113
+ }
114
+ ids.sort();
115
+ if (listing.truncated)
116
+ skipped.push(`${RUN_NOTES_DIR}/ holds more than ${MAX_LISTED_ENTRIES} entries; only the first ${MAX_LISTED_ENTRIES} were listed.`);
117
+ if (ids.length > MAX_RUN_NOTES) {
118
+ const left = ids.length - MAX_RUN_NOTES;
119
+ skipped.push(`${left} note${left === 1 ? "" : "s"} after the first ${MAX_RUN_NOTES} ${left === 1 ? "was" : "were"} not read.`);
120
+ ids.length = MAX_RUN_NOTES;
121
+ }
122
+ return { ids, skipped };
123
+ }
124
+ /** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
125
+ export async function countRunNotes(prepared) {
126
+ const listing = await listNoteEntries(path.join(prepared.physicalRunRoot, RUN_NOTES_DIR));
127
+ return listing === null ? 0 : noteListing(listing).ids.length + (listing.truncated ? 1 : 0);
128
+ }
129
+ /** The run's note files through the bounded reader, which rechecks the run directory on each read. */
130
+ function boundedNoteFiles(prepared) {
131
+ return {
132
+ runId: runIdOf(prepared),
133
+ async directory() {
134
+ await validatePreparedRunRootIdentity(prepared);
135
+ return prepared.physicalRunRoot;
136
+ },
137
+ async read(relativePath, maxBytes) {
138
+ const read = await readBoundedFileResult(prepared, relativePath, maxBytes);
139
+ return read.state === "read" ? read.bytes : read.state === "limit" ? "limit" : null;
140
+ },
141
+ };
142
+ }
143
+ /**
144
+ * The run's notes, read from its prepared paths through the bounded reader or through `from`, the
145
+ * files of a run the caller already holds (the served Observer passes its pinned root's). Each file
146
+ * is read within MAX_NOTE_FILE_BYTES; a file the reader refuses, or that is not a note of this run,
147
+ * is skipped and named in `skipped`.
148
+ */
149
+ export async function readRunNotes(from) {
150
+ const files = "physicalRunRoot" in from ? boundedNoteFiles(from) : from;
151
+ const { runId } = files;
152
+ let listing;
153
+ try {
154
+ listing = await listNoteEntries(path.join(await files.directory(), RUN_NOTES_DIR));
155
+ }
156
+ catch {
157
+ return {
158
+ runId,
159
+ notes: [],
160
+ skipped: [`${RUN_NOTES_DIR} in run ${runId} is not a plain folder, so no notes were read.`],
161
+ };
162
+ }
163
+ if (listing === null)
164
+ return { runId, notes: [], skipped: [] };
165
+ const { ids, skipped } = noteListing(listing);
166
+ const notes = [];
167
+ for (const id of ids) {
168
+ const read = await files.read(runNoteFile(id), MAX_NOTE_FILE_BYTES);
169
+ const note = Buffer.isBuffer(read) ? decodeRunNote(read, runId, id) : null;
170
+ if (note !== null)
171
+ notes.push(note);
172
+ else
173
+ skipped.push(read === "limit"
174
+ ? `${runNoteFile(id)} is over ${MAX_NOTE_FILE_BYTES / 1024} KiB, so it was skipped.`
175
+ : `${runNoteFile(id)} is not a readable note of run ${runId}, so it was skipped.`);
176
+ }
177
+ return { runId, notes, skipped };
178
+ }
179
+ /**
180
+ * The run's notes as a caller is about to share them, read once, with what verify's text scan
181
+ * finds in that set as it would be shared. The caller shares this set only, so a note added or
182
+ * changed after verify ran cannot go out unchecked.
183
+ */
184
+ export async function readNotesForSharing(prepared) {
185
+ const notes = await readRunNotes(prepared);
186
+ if (notes.notes.length === 0)
187
+ return { notes, problem: null };
188
+ const scan = scanEncodedTextCached(JSON.stringify(notes.notes));
189
+ if (scan.sensitive)
190
+ return {
191
+ notes,
192
+ problem: {
193
+ status: "blocked",
194
+ reason: {
195
+ code: "PUBLIC_SAFETY_FINDINGS",
196
+ message: "The reviewer notes being shared match secret, token or local-path patterns.",
197
+ },
198
+ },
199
+ };
200
+ if (scan.opaque)
201
+ return {
202
+ notes,
203
+ problem: {
204
+ status: "local_only",
205
+ reason: {
206
+ code: "UNSCANNED_ARTIFACT",
207
+ message: "The reviewer notes being shared hold encoded text the scan cannot read.",
208
+ },
209
+ },
210
+ };
211
+ return { notes, problem: null };
212
+ }
@@ -0,0 +1,35 @@
1
+ /** The characters a note's text may hold. */
2
+ export declare const MAX_NOTE_TEXT = 2000;
3
+ /** The notes one run keeps. A reader reads this many and names the rest. */
4
+ export declare const MAX_RUN_NOTES = 500;
5
+ /** The characters of a participant id, a trace item id or a timestamp a note records. */
6
+ export declare const NOTE_FIELD_LIMITS: {
7
+ participant: number;
8
+ itemId: number;
9
+ author: number;
10
+ timestamp: number;
11
+ };
12
+ /** A note id: `note-<creation time>-<48 random bits in hex>`. */
13
+ export declare const NOTE_ID: RegExp;
14
+ /** Where an Observer page sends a note to the loopback server that rendered it. */
15
+ export declare const NOTES_PATH = "/api/notes";
16
+ /** The request header that carries the server's note token. */
17
+ export declare const NOTES_TOKEN_HEADER = "x-humanish-notes-token";
18
+ export interface RunNote {
19
+ id: string;
20
+ /** Milliseconds from the run clock's start. */
21
+ atMs: number;
22
+ /** The participant's stream id, or null for a note on the whole run. */
23
+ participant: string | null;
24
+ /** The latest timed capture or event at or before the moment, with its participant. */
25
+ nearest: {
26
+ participant: string;
27
+ itemId: string;
28
+ } | null;
29
+ text: string;
30
+ author: string;
31
+ createdAt: string;
32
+ editedAt: string | null;
33
+ }
34
+ /** Notes in run clock order, the earliest added first at the same moment. */
35
+ export declare function byRunTime(notes: readonly RunNote[]): RunNote[];
@@ -0,0 +1,20 @@
1
+ // A reviewer note as the server keeps it and the Observer shows it: its fields, their limits, and
2
+ // where an Observer page sends a new one. The Observer bundles this module, so it has no runtime imports
3
+ // (observer/tests/contract-lock.test.ts checks that); the server validates notes against these
4
+ // limits with zod in note-files.ts and notes-route.ts.
5
+ /** The characters a note's text may hold. */
6
+ export const MAX_NOTE_TEXT = 2000;
7
+ /** The notes one run keeps. A reader reads this many and names the rest. */
8
+ export const MAX_RUN_NOTES = 500;
9
+ /** The characters of a participant id, a trace item id or a timestamp a note records. */
10
+ export const NOTE_FIELD_LIMITS = { participant: 256, itemId: 256, author: 80, timestamp: 64 };
11
+ /** A note id: `note-<creation time>-<48 random bits in hex>`. */
12
+ export const NOTE_ID = /^note-\d{8}t\d{9}z-[0-9a-f]{12}$/;
13
+ /** Where an Observer page sends a note to the loopback server that rendered it. */
14
+ export const NOTES_PATH = "/api/notes";
15
+ /** The request header that carries the server's note token. */
16
+ export const NOTES_TOKEN_HEADER = "x-humanish-notes-token";
17
+ /** Notes in run clock order, the earliest added first at the same moment. */
18
+ export function byRunTime(notes) {
19
+ return [...notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
20
+ }
@@ -0,0 +1,34 @@
1
+ import type { RunBundle } from "./bundle.js";
2
+ import { type RunNote } from "./note-shape.js";
3
+ import { type PreparedRunArtifactPaths } from "./paths.js";
4
+ export interface RunNoteInput {
5
+ atMs: number;
6
+ participant: string | null;
7
+ text: string;
8
+ }
9
+ 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";
10
+ export type AddRunNoteResult =
11
+ /** `scrubbed`: redaction replaced part of the text before it was written. */
12
+ {
13
+ ok: true;
14
+ note: RunNote;
15
+ scrubbed: boolean;
16
+ } | {
17
+ ok: false;
18
+ error: {
19
+ code: RunNoteErrorCode;
20
+ message: string;
21
+ };
22
+ };
23
+ /** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
24
+ export declare function formatRunTime(ms: number): string;
25
+ /** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
26
+ export declare function runClock(bundle: RunBundle): {
27
+ startMs: number;
28
+ endMs: number;
29
+ } | null;
30
+ /**
31
+ * Adds a note as a new file, notes/<id>.json, created under a name nothing holds. It reads no other
32
+ * note and replaces nothing, so notes added at the same time, from any process, are all kept.
33
+ */
34
+ export declare function addRunNote(prepared: PreparedRunArtifactPaths, input: RunNoteInput): Promise<AddRunNoteResult>;