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.
Files changed (68) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +1 -1
  4. package/dist/actors/computer-use/openai-conversation.js +9 -7
  5. package/dist/actors/contract.d.ts +4 -2
  6. package/dist/analysis/admission.d.ts +37 -0
  7. package/dist/analysis/admission.js +60 -0
  8. package/dist/analysis/automatic-completion.d.ts +0 -6
  9. package/dist/analysis/automatic-completion.js +3 -3
  10. package/dist/analysis/automatic-config.d.ts +10 -2
  11. package/dist/analysis/automatic-config.js +29 -14
  12. package/dist/analysis/automatic.js +7 -0
  13. package/dist/analysis/execute.d.ts +19 -5
  14. package/dist/analysis/execute.js +58 -27
  15. package/dist/analysis/image-tokens.d.ts +2 -0
  16. package/dist/analysis/image-tokens.js +1 -1
  17. package/dist/analysis/job.d.ts +9 -1
  18. package/dist/analysis/job.js +11 -2
  19. package/dist/analysis/service.js +6 -3
  20. package/dist/cli/commands/analyze.js +7 -2
  21. package/dist/cli/commands/notes.d.ts +3 -0
  22. package/dist/cli/commands/notes.js +144 -0
  23. package/dist/cli/commands/observe.js +1 -1
  24. package/dist/cli/commands/study-run.js +2 -1
  25. package/dist/cli/findings.js +4 -1
  26. package/dist/cli/io.d.ts +1 -0
  27. package/dist/cli/io.js +11 -12
  28. package/dist/cli/program.js +2 -0
  29. package/dist/evidence/encoded-text.d.ts +15 -2
  30. package/dist/evidence/encoded-text.js +68 -4
  31. package/dist/evidence/secret-scrub.d.ts +1 -1
  32. package/dist/evidence/secret-scrub.js +23 -2
  33. package/dist/feedback/draft.d.ts +19 -0
  34. package/dist/feedback/draft.js +51 -1
  35. package/dist/feedback/export-bundle.js +2 -2
  36. package/dist/feedback/export.js +47 -3
  37. package/dist/feedback/feedback.js +42 -2
  38. package/dist/observer/artifact.d.ts +5 -0
  39. package/dist/observer/artifact.js +18 -2
  40. package/dist/observer/notes-route.d.ts +21 -0
  41. package/dist/observer/notes-route.js +140 -0
  42. package/dist/observer/pinned-files.d.ts +8 -1
  43. package/dist/observer/pinned-files.js +32 -3
  44. package/dist/observer/render.js +38 -14
  45. package/dist/observer/run-routes.d.ts +8 -2
  46. package/dist/observer/run-routes.js +47 -6
  47. package/dist/observer/serve.d.ts +3 -0
  48. package/dist/observer/serve.js +28 -3
  49. package/dist/observer-app.html +7 -6
  50. package/dist/run/contained-output.d.ts +7 -0
  51. package/dist/run/contained-output.js +35 -1
  52. package/dist/run/note-files.d.ts +73 -0
  53. package/dist/run/note-files.js +184 -0
  54. package/dist/run/notes.d.ts +37 -0
  55. package/dist/run/notes.js +198 -0
  56. package/dist/run/participant-caption.d.ts +9 -0
  57. package/dist/run/participant-caption.js +19 -0
  58. package/dist/study/doctor.js +1 -1
  59. package/dist/study/preflight-result.js +2 -1
  60. package/dist/study/summary.js +2 -2
  61. package/docs/contracts/errors.md +2 -2
  62. package/docs/contracts/run-bundle.md +57 -0
  63. package/docs/contracts/schemas.md +5 -2
  64. package/docs/contracts/study-analysis.md +26 -8
  65. package/docs/principles/invariants-and-defaults.md +2 -2
  66. package/docs/product/automatic-analysis.md +22 -16
  67. package/package.json +2 -2
  68. package/skills/humanish/SKILL.md +5 -0
@@ -12,12 +12,14 @@ import { loadAnalysis } from "../analysis/load.js";
12
12
  import { physicalCwdOf, runIdOf, bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
13
13
  import { writeContainedOutputFile } from "../run/contained-output.js";
14
14
  import { loadRunBundlePrepared, readRunJsonIfExists } from "../run/locate.js";
15
+ import { readRunNotes } from "../run/notes.js";
15
16
  import { RUN_STATUS_FILE } from "../run/status.js";
16
17
  import { verifyRunPrepared } from "../verify/verify.js";
17
18
  import { renderObserverHtml } from "./artifact.js";
18
19
  import { buildObserverData } from "./data.js";
19
20
  import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./http.js";
20
21
  import { listenOnLoopback } from "./listen.js";
22
+ import { createNotesWriter, NOTES_PATH } from "./notes-route.js";
21
23
  import { pinDirectChildDirectory, pinDirectory, readContainedFile, } from "./pinned-files.js";
22
24
  import { buildHistoryIndex, matchRunRoute, serveRunPath, writeResponse, } from "./run-routes.js";
23
25
  import { cli } from "../cli/invocation.js";
@@ -114,7 +116,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
114
116
  };
115
117
  await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(observerData, null, 2)}\n`, "utf8");
116
118
  await writeContainedOutputFile(preparedRunPaths, path.join("observer", "study-analysis.json"), `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
117
- await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis }), "utf8");
119
+ await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis, notes: await readRunNotes(preparedRunPaths) }), "utf8");
118
120
  await validatePreparedRunArtifactPaths(preparedRunPaths);
119
121
  const relativeObserverPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "index.html");
120
122
  const relativeObserverDataPath = path.join(preparedRunPaths.relativeRunRoot, "observer", "observer-data.json");
@@ -180,6 +182,16 @@ async function resolveObserverRunSelection(cwd, runInput) {
180
182
  return null;
181
183
  return { runId: pointer.runId, runRoot, runsRoot };
182
184
  }
185
+ /**
186
+ * The served run history. Exposed watch and selected-run viewers pass `onlyRun` and cannot
187
+ * enumerate other runs; full-library loopback viewers get the complete project index.
188
+ */
189
+ async function writeHistory(response, proofRoot, onlyRun) {
190
+ const history = await buildHistoryIndex(proofRoot);
191
+ const runs = onlyRun === null ? history.runs : history.runs.filter((run) => run.runId === onlyRun);
192
+ const listed = onlyRun === null ? history : { latestRunId: runs.length > 0 ? onlyRun : null, runs };
193
+ writeResponse(response, 200, JSON.stringify(listed, null, 2), "application/json; charset=utf-8");
194
+ }
183
195
  export async function serveObserver(result, options = {}) {
184
196
  if (!result.ok || !result.observerPath) {
185
197
  throw new Error("Cannot serve an observer result that did not render successfully.");
@@ -206,6 +218,14 @@ export async function serveObserver(result, options = {}) {
206
218
  const runtimeStreamUrls = () => observerRuntimeStreamUrls.get(result) ?? [];
207
219
  const exposed = options.exposed === true;
208
220
  const scopedToRun = exposed || options.scope === "run";
221
+ let port = 0;
222
+ // Notes are taken for the selected run only, whose paths this server already holds.
223
+ const notes = createNotesWriter({
224
+ exposed,
225
+ port: () => port,
226
+ resolveRun: async (runId) => (runId === result.run ? preparedRunPaths : null),
227
+ refresh: (prepared) => renderObserver(cwd, result.run, { open: false, expectedRun: prepared }),
228
+ });
209
229
  // Host allowlist for exposed mode (DNS-rebinding defense, identical to the run-library surface).
210
230
  // Seeded with the loopback names after bind; addPublicOrigin extends it with the tunnel/public-url
211
231
  // host. Never consulted in loopback (non-exposed) mode.
@@ -224,21 +244,22 @@ export async function serveObserver(result, options = {}) {
224
244
  }
225
245
  }
226
246
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
247
+ if (url.pathname === NOTES_PATH) {
248
+ await notes.handle(request, response);
249
+ return;
250
+ }
251
+ // Adding a note is the only write; nothing else is read with another method.
252
+ if (request.method !== "GET" && request.method !== "HEAD") {
253
+ writeResponse(response, 405, "Method Not Allowed", "text/plain; charset=utf-8");
254
+ return;
255
+ }
227
256
  if (url.pathname === "/") {
228
257
  response.writeHead(302, { location: "/observer/index.html" });
229
258
  response.end();
230
259
  return;
231
260
  }
232
261
  if (url.pathname === "/_humanish/history.json") {
233
- const history = await buildHistoryIndex(proofRoot);
234
- // Exposed watch and selected-run viewers cannot enumerate other runs. Full-library
235
- // loopback viewers retain the complete project index.
236
- if (scopedToRun) {
237
- const attachedRuns = history.runs.filter((entry) => entry.runId === result.run);
238
- writeResponse(response, 200, JSON.stringify({ latestRunId: attachedRuns.length > 0 ? result.run : null, runs: attachedRuns }, null, 2), "application/json; charset=utf-8");
239
- return;
240
- }
241
- writeResponse(response, 200, JSON.stringify(history, null, 2), "application/json; charset=utf-8");
262
+ await writeHistory(response, proofRoot, scopedToRun ? result.run : null);
242
263
  return;
243
264
  }
244
265
  if (url.pathname.startsWith("/_humanish/runs/")) {
@@ -257,10 +278,10 @@ export async function serveObserver(result, options = {}) {
257
278
  writeResponse(response, 404, "Run not found", "text/plain; charset=utf-8");
258
279
  return;
259
280
  }
260
- await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request);
281
+ await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, runRoute.runId === result.run ? runtimeStreamUrls() : [], request, runRoute.runId === result.run ? notes.pageToken(request) : undefined);
261
282
  return;
262
283
  }
263
- await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request);
284
+ await serveRunPath(runRoot, decodeURIComponent(url.pathname.slice(1)), response, runtimeStreamUrls(), request, notes.pageToken(request));
264
285
  }
265
286
  catch {
266
287
  writeResponse(response, 500, "Observer request failed", "text/plain; charset=utf-8");
@@ -269,7 +290,7 @@ export async function serveObserver(result, options = {}) {
269
290
  const server = createServer((request, response) => {
270
291
  void handleRequest(request, response);
271
292
  });
272
- const port = await listenOnLoopback(server, options.port ?? 0);
293
+ port = await listenOnLoopback(server, options.port ?? 0);
273
294
  if (exposed) {
274
295
  hostAllowlist.add(`127.0.0.1:${port}`);
275
296
  hostAllowlist.add(`localhost:${port}`);
@@ -295,7 +316,10 @@ export async function serveObserver(result, options = {}) {
295
316
  hostAllowlist.add(parsed.host);
296
317
  }
297
318
  },
298
- close: () => closeServer(server),
319
+ close: async () => {
320
+ await notes.settled();
321
+ await closeServer(server);
322
+ },
299
323
  };
300
324
  }
301
325
  /**
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type PreparedRunArtifactPaths } from "../run/paths.js";
2
3
  import type { RunDisplay } from "../run/display.js";
3
4
  import { type ObserverData } from "./data.js";
4
5
  import { type PinnedDirectory } from "./pinned-files.js";
@@ -10,8 +11,13 @@ export interface ObserverRuntimeStreamUrl {
10
11
  * evidence (keyframe replay/screenshot) instead of rendering "sandbox not found". */
11
12
  ended?: boolean;
12
13
  }
13
- /** internal: consumed by src/observer/serve.ts */
14
- export declare function serveRunPath(runRoot: PinnedDirectory, relativePath: string, response: ServerResponse, runtimeStreamUrls?: ObserverRuntimeStreamUrl[], request?: Pick<IncomingMessage, "method" | "headers">): Promise<void>;
14
+ /** The run's artifact paths, only when they are still the pinned directory being served. */
15
+ export declare function servedRunPaths(runRoot: PinnedDirectory): Promise<PreparedRunArtifactPaths>;
16
+ /**
17
+ * internal: consumed by src/observer/serve.ts. `notesToken` goes into the run's Observer page so
18
+ * it can add notes; a server passes it only for a run it takes notes for.
19
+ */
20
+ export declare function serveRunPath(runRoot: PinnedDirectory, relativePath: string, response: ServerResponse, runtimeStreamUrls?: ObserverRuntimeStreamUrl[], request?: Pick<IncomingMessage, "method" | "headers">, notesToken?: string): Promise<void>;
15
21
  /** internal: exported for the stream lifecycle tests. */
16
22
  export declare function withRuntimeStreamUrls(data: ObserverData, runtimeStreamUrls: ObserverRuntimeStreamUrl[]): ObserverData;
17
23
  /** internal: consumed by src/observer/serve.ts */
@@ -9,6 +9,7 @@ import { listRuns } from "../run/stored-runs.js";
9
9
  import { readAnalysisAccounting } from "../run/costs.js";
10
10
  import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
11
11
  import { isRunOutcome } from "../run/bundle-shape.js";
12
+ import { decodeRunNote, listNoteEntries, MAX_NOTE_FILE_BYTES, noteListing, RUN_NOTES_DIR, runNoteFile, } from "../run/note-files.js";
12
13
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
13
14
  import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
14
15
  import { renderObserverHtml } from "./artifact.js";
@@ -16,7 +17,7 @@ import { buildObserverData, recordedStreamEmbed, withObserverEndings, } from "./
16
17
  import { buildArtifactSecurityHeaders } from "./http.js";
17
18
  import { assertPinnedDirectory, openContainedFile, pinDirectChildDirectory, readContainedFile, relativeToRoot, sha256OfOpenedFile, } from "./pinned-files.js";
18
19
  /** The run's artifact paths, only when they are still the pinned directory being served. */
19
- async function servedRunPaths(runRoot) {
20
+ export async function servedRunPaths(runRoot) {
20
21
  const runId = path.basename(runRoot.physicalPath);
21
22
  const cwd = path.dirname(path.dirname(path.dirname(runRoot.physicalPath)));
22
23
  const prepared = await bindExistingRunArtifactPaths(cwd, runId);
@@ -57,8 +58,40 @@ async function readServedAnalysisSpend(runRoot) {
57
58
  return undefined;
58
59
  }
59
60
  }
60
- /** internal: consumed by src/observer/serve.ts */
61
- export async function serveRunPath(runRoot, relativePath, response, runtimeStreamUrls = [], request) {
61
+ /**
62
+ * The run's reviewer notes as served: notes/ listed, and each note file read within
63
+ * MAX_NOTE_FILE_BYTES through the root's checks, so a --safe root's admission and hash checks
64
+ * apply to every note. A file that is not a readable note of the run is skipped and counted.
65
+ */
66
+ async function readServedNotes(runRoot) {
67
+ const runId = path.basename(runRoot.physicalPath);
68
+ let listing;
69
+ try {
70
+ await assertPinnedDirectory(runRoot);
71
+ listing = await listNoteEntries(path.join(runRoot.physicalPath, RUN_NOTES_DIR));
72
+ }
73
+ catch {
74
+ return { runId, notes: [], skipped: [`${RUN_NOTES_DIR} is not a plain folder.`] };
75
+ }
76
+ if (listing === null)
77
+ return { runId, notes: [], skipped: [] };
78
+ const { ids, skipped } = noteListing(listing);
79
+ const notes = [];
80
+ for (const id of ids) {
81
+ const bytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, runNoteFile(id)), { maxBytes: MAX_NOTE_FILE_BYTES });
82
+ const note = bytes === null ? null : decodeRunNote(bytes, runId, id);
83
+ if (note === null)
84
+ skipped.push(`${runNoteFile(id)} was skipped.`);
85
+ else
86
+ notes.push(note);
87
+ }
88
+ return { runId, notes, skipped };
89
+ }
90
+ /**
91
+ * internal: consumed by src/observer/serve.ts. `notesToken` goes into the run's Observer page so
92
+ * it can add notes; a server passes it only for a run it takes notes for.
93
+ */
94
+ export async function serveRunPath(runRoot, relativePath, response, runtimeStreamUrls = [], request, notesToken) {
62
95
  const root = runRoot.physicalPath;
63
96
  const filePath = path.resolve(root, relativePath === "" ? "observer/index.html" : relativePath);
64
97
  if (!isPathInside(root, filePath)) {
@@ -87,7 +120,12 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
87
120
  return;
88
121
  }
89
122
  const analysis = await readObserverAnalysis(runRoot);
90
- writeResponse(response, 200, renderObserverHtml(observerData, { analysis }), "text/html; charset=utf-8");
123
+ const notes = await readServedNotes(runRoot);
124
+ writeResponse(response, 200, renderObserverHtml(observerData, {
125
+ analysis,
126
+ notes,
127
+ ...(notesToken === undefined ? {} : { notesToken }),
128
+ }), "text/html; charset=utf-8");
91
129
  return;
92
130
  }
93
131
  if (cleanedRelativePath === "observer/observer-data.json") {
@@ -103,7 +141,10 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
103
141
  writeResponse(response, 200, JSON.stringify(await readObserverAnalysis(runRoot)), "application/json; charset=utf-8");
104
142
  return;
105
143
  }
106
- if (path.extname(filePath).toLowerCase() === ".mp4") {
144
+ // Every file under notes/ is read within the note limit, whatever its name, so media handling
145
+ // never streams one.
146
+ const notePath = derivedRoot === RUN_NOTES_DIR;
147
+ if (!notePath && path.extname(filePath).toLowerCase() === ".mp4") {
107
148
  await serveContainedMedia(runRoot, filePath, response, request);
108
149
  return;
109
150
  }
@@ -112,7 +153,7 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
112
153
  return;
113
154
  }
114
155
  try {
115
- const body = await readContainedFile(runRoot, filePath);
156
+ const body = await readContainedFile(runRoot, filePath, notePath ? { maxBytes: MAX_NOTE_FILE_BYTES } : {});
116
157
  if (!body) {
117
158
  writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
118
159
  return;
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type NotesWriter } from "./notes-route.js";
2
3
  import type { PinnedDirectory } from "./pinned-files.js";
3
4
  import { type AdmittedRun } from "./run-inventory.js";
4
5
  import type { LibraryHistory } from "./library.js";
@@ -70,6 +71,8 @@ export declare function createShareSafetyAdmission(cwd: string, options?: {
70
71
  }): ShareSafetyAdmission;
71
72
  export interface ServeRequestHandlerOptions {
72
73
  proofRoot: PinnedDirectory;
74
+ /** Takes reviewer notes on loopback; without it the library refuses them. */
75
+ notes?: NotesWriter;
73
76
  safe: boolean;
74
77
  admit: (runId: string) => Promise<AdmittedRun | null>;
75
78
  hostAllowlist: ReadonlySet<string>;
@@ -3,7 +3,9 @@ import { runNotFoundMessage } from "../run/run-not-found.js";
3
3
  import { createServer } from "node:http";
4
4
  import path from "node:path";
5
5
  import { pinDirectChildDirectory, pinDirectory } from "./pinned-files.js";
6
- import { buildHistoryIndex, matchRunRoute, serveRunPath } from "./run-routes.js";
6
+ import { createNotesWriter, NOTES_PATH } from "./notes-route.js";
7
+ import { renderObserver } from "./render.js";
8
+ import { buildHistoryIndex, matchRunRoute, servedRunPaths, serveRunPath } from "./run-routes.js";
7
9
  import { hashRunInventory, inventoryRoot, readRunInventory, } from "./run-inventory.js";
8
10
  import { renderLibraryHtml } from "./library.js";
9
11
  import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin, } from "./http.js";
@@ -104,6 +106,13 @@ export function createServeRequestHandler(options) {
104
106
  for (const [name, value] of Object.entries(buildServeSecurityHeaders())) {
105
107
  response.setHeader(name, value);
106
108
  }
109
+ if (new URL(request.url ?? "/", "http://127.0.0.1").pathname === NOTES_PATH) {
110
+ if (options.notes)
111
+ await options.notes.handle(request, response);
112
+ else
113
+ writeText(response, 405, "Method Not Allowed");
114
+ return;
115
+ }
107
116
  const method = request.method ?? "GET";
108
117
  if (method !== "GET" && method !== "HEAD") {
109
118
  writeText(response, 405, "Method Not Allowed");
@@ -161,7 +170,7 @@ export function createServeRequestHandler(options) {
161
170
  }
162
171
  // The explicit empty runtimeStreamUrls keeps a future refactor from
163
172
  // reintroducing auth-keyed stream injection on the serve surface.
164
- await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, [], request);
173
+ await serveRunPath(targetRoot, runRoute.relativePath || "observer/index.html", response, [], request, options.notes?.pageToken(request));
165
174
  return;
166
175
  }
167
176
  writeText(response, 404, "Not found");
@@ -260,8 +269,24 @@ export async function serveObserverLibrary(cwdInput, options) {
260
269
  }
261
270
  const declaredOrigin = options.publicOrigin ? parsePublicOrigin(options.publicOrigin) : null;
262
271
  const hostAllowlist = new Set();
272
+ let port = 0;
273
+ const notes = createNotesWriter({
274
+ exposed: mode !== "loopback",
275
+ port: () => port,
276
+ resolveRun: async (runId) => {
277
+ const pinned = await pinDirectChildDirectory(proofRoot, runId);
278
+ if (!pinned || (options.safe && (await admission.admit(runId)) === null))
279
+ return null;
280
+ return servedRunPaths(pinned);
281
+ },
282
+ refresh: (prepared) => renderObserver(cwd, path.basename(prepared.physicalRunRoot), {
283
+ open: false,
284
+ expectedRun: prepared,
285
+ }),
286
+ });
263
287
  const handler = createServeRequestHandler({
264
288
  proofRoot,
289
+ notes,
265
290
  safe: options.safe,
266
291
  admit: (runId) => admission.admit(runId),
267
292
  hostAllowlist,
@@ -276,7 +301,6 @@ export async function serveObserverLibrary(cwdInput, options) {
276
301
  const server = createServer((request, response) => {
277
302
  void handler(request, response);
278
303
  });
279
- let port;
280
304
  try {
281
305
  port = await listenOnLoopback(server, options.port);
282
306
  }
@@ -314,6 +338,7 @@ export async function serveObserverLibrary(cwdInput, options) {
314
338
  hostAllowlist.add(parsed.host);
315
339
  },
316
340
  close: async () => {
341
+ await notes.settled();
317
342
  const closed = closeServer(server);
318
343
  // Keep-alive sockets would otherwise keep close() pending past the point
319
344
  // the operator believes Ctrl-C tore the server down.