humanish 0.37.0 → 0.39.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.
@@ -0,0 +1,109 @@
1
+ // Resolve committed persona files into compiled personas, for EVERY lane (#381).
2
+ //
3
+ // #308 gave the terminal lane this: read `humanish/personas/<id>.yaml`, compile the traits into
4
+ // behavioral directives, and record truthfully which traits were applied. The computer-use lanes
5
+ // never got it — they composed a bare `Persona: <id>.` line and hardcoded `traitsApplied: []`, so
6
+ // on browser routes the persona axis was a label rather than a behavior. A live two-lane contrast
7
+ // (an impatient expert vs a patient newcomer, same app, same mission) came back with near-identical
8
+ // action profiles, which read like evidence that personas do not matter and was actually evidence
9
+ // that personas were never applied.
10
+ //
11
+ // This module is the shared implementation so the two routes cannot drift again: one containment
12
+ // rule, one compiler, one fallback polarity.
13
+ //
14
+ // FAIL-SAFE, not fail-closed — deliberately the opposite polarity to the scorer loader. A persona
15
+ // that declared nothing must never be given fabricated traits, so an unsafe id, a missing file, or
16
+ // unparseable YAML falls back to the bare id line with a truthful EMPTY traitsApplied (warning only
17
+ // when the file existed but could not be read). Silence here is honest; invention would not be.
18
+ import { parse as parseYaml } from "yaml";
19
+ import path from "node:path";
20
+ import { parseResolvedPersona } from "./persona.js";
21
+ import { prepareSelectedOutputDirectory, readContainedRegularFile } from "./selected-output-paths.js";
22
+ import { realpath } from "node:fs/promises";
23
+ /** Persona ids are file-name segments, never paths: the same grammar the terminal lane enforces. */
24
+ export const PERSONA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
25
+ /** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
26
+ export function personaTitleFromId(personaId) {
27
+ return personaId
28
+ .split(/[-_]/)
29
+ .filter(Boolean)
30
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
31
+ .join(" ");
32
+ }
33
+ /**
34
+ * Resolve ONE committed persona. Returns `null` (never a throw, never a guess) when the id is
35
+ * unsafe or no file exists, so a lane always runs.
36
+ */
37
+ export async function resolveCommittedPersona(projectRoot, personaId) {
38
+ if (!PERSONA_ID_PATTERN.test(personaId)) {
39
+ return { persona: null, warnings: [] };
40
+ }
41
+ for (const candidate of [
42
+ path.posix.join("humanish", "personas", `${personaId}.yaml`),
43
+ path.posix.join("humanish", "personas", `${personaId}.yml`)
44
+ ]) {
45
+ const bytes = await readContainedRegularFile(projectRoot, candidate);
46
+ if (!bytes)
47
+ continue;
48
+ let raw;
49
+ try {
50
+ raw = parseYaml(bytes.toString("utf8"));
51
+ }
52
+ catch {
53
+ return {
54
+ persona: null,
55
+ warnings: [`${candidate} could not be parsed as YAML; the lane ran with the persona id only (no traits applied).`]
56
+ };
57
+ }
58
+ return { persona: parseResolvedPersona(raw, { id: personaId, name: personaTitleFromId(personaId) }), warnings: [] };
59
+ }
60
+ return { persona: null, warnings: [] };
61
+ }
62
+ /**
63
+ * Resolve every distinct persona id a run will use, once, before lane specs are built. Returning a
64
+ * map keeps the plan builder PURE (it is exported npm surface and asserted pure by tests): the
65
+ * async file reads happen here, and the composer only does a lookup.
66
+ */
67
+ export async function resolveCommittedPersonas(projectRoot, personaIds) {
68
+ const personas = new Map();
69
+ const warnings = [];
70
+ for (const personaId of new Set(personaIds.filter((id) => typeof id === "string" && id.length > 0))) {
71
+ const resolved = await resolveCommittedPersona(projectRoot, personaId);
72
+ if (resolved.persona)
73
+ personas.set(personaId, resolved.persona);
74
+ warnings.push(...resolved.warnings);
75
+ }
76
+ return { personas, warnings };
77
+ }
78
+ /**
79
+ * Every persona id a lab config could put on a browser lane: the per-lane roster when one is
80
+ * declared, otherwise the actor-level persona that every fan-out lane inherits.
81
+ */
82
+ export function labPersonaIds(config) {
83
+ const ids = [];
84
+ for (const actor of config.actors ?? []) {
85
+ if (actor.persona)
86
+ ids.push(actor.persona);
87
+ for (const lane of actor.lanes ?? []) {
88
+ if (lane.persona)
89
+ ids.push(lane.persona);
90
+ }
91
+ }
92
+ return [...new Set(ids)];
93
+ }
94
+ /**
95
+ * Same resolution from a plain cwd, for the labs that carry a directory string rather than an
96
+ * already-prepared root. Realpath-then-prepare mirrors the cua lab so a symlinked cwd still reads
97
+ * personas from the physical project.
98
+ */
99
+ export async function resolveCommittedPersonasForCwd(cwd, personaIds) {
100
+ try {
101
+ const physical = await realpath(path.resolve(cwd));
102
+ const projectRoot = await prepareSelectedOutputDirectory(path.dirname(physical), physical);
103
+ return await resolveCommittedPersonas(projectRoot, personaIds);
104
+ }
105
+ catch {
106
+ return { personas: new Map(), warnings: [] };
107
+ }
108
+ }
109
+ //# sourceMappingURL=persona-resolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persona-resolve.js","sourceRoot":"","sources":["../src/persona-resolve.ts"],"names":[],"mappings":"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"}
package/dist/program.js CHANGED
@@ -22,6 +22,8 @@ import { DEFAULT_OSS_REPOS, runOssLab } from "./oss-lab.js";
22
22
  import { cleanupOssMetaLabSandboxes, cleanupStaleOssMetaLabSandboxes, runOssMetaLab, startOssMetaLabLiveRefresh } from "./oss-meta-lab.js";
23
23
  import { cleanupRun, doctor, listRuns, readReview, runDryRun, verifyRun } from "./run.js";
24
24
  import { reclaimRunSandboxes } from "./reclaim.js";
25
+ import { runCommsCatchHost } from "./comms-catch-host.js";
26
+ import { DEFAULT_SANDBOX_CATCH_PORT } from "./comms-sandbox-catch.js";
25
27
  export const CLI_RESPONSE_SCHEMA = "humanish.cli-response.v1";
26
28
  function readCliVersion() {
27
29
  const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
@@ -167,6 +169,7 @@ export function createProgram(io = {}) {
167
169
  registerCleanupCommand(program, cliIo);
168
170
  registerReviewCommand(program, cliIo);
169
171
  registerRunsCommand(program, cliIo);
172
+ registerCommsCommands(program, cliIo);
170
173
  registerReclaimCommand(program, cliIo);
171
174
  registerWatchCommand(program, cliIo);
172
175
  registerObserveCommand(program, cliIo);
@@ -372,6 +375,50 @@ function registerRunsCommand(parent, io) {
372
375
  io.setExitCode(result.ok ? 0 : 2);
373
376
  });
374
377
  }
378
+ function registerCommsCommands(parent, io) {
379
+ const comms = parent
380
+ .command("comms")
381
+ .description("Off-app comms surfaces (email/SMS the app under test sends).")
382
+ .summary("Off-app comms surfaces.");
383
+ comms
384
+ .command("catch")
385
+ .description("Run the email catch on THIS host so humanish can study an app it does not provision (#328). Your app posts its email sends here; the persona opens /inbox; humanish drains GET /deliveries and writes digest-only evidence. Point your lab's comms.email.external.catchBaseUrl at this server.")
386
+ .summary("Run the adopter-hosted email catch.")
387
+ .option("--port <port>", "Port for capture + inbox (default 8025).", String(DEFAULT_SANDBOX_CATCH_PORT))
388
+ .option("--dir <path>", "Directory for the deliveries log and rendered inbox.", ".humanish/comms-catch")
389
+ .option("--token <value>", "Require this bearer token on GET /deliveries (recommended when reachable off-host).")
390
+ .option("--inbox-port <port>", "Also serve a READ-ONLY inbox listener on 0.0.0.0:<port>, so a persona on another machine can open /inbox. Without it the catch stays loopback-only.")
391
+ .option("--recipient <address>", "Only render mail sent to this address (repeatable). Default: render whatever the app actually mailed.", (value, previous = []) => [...previous, value])
392
+ .action(async (options) => {
393
+ const port = Number.parseInt(options.port, 10);
394
+ if (!Number.isInteger(port) || port <= 0 || port > 65_534) {
395
+ io.writeErr("--port must be an integer between 1 and 65534.\n");
396
+ io.setExitCode(2);
397
+ return;
398
+ }
399
+ let inboxPort;
400
+ if (options.inboxPort !== undefined) {
401
+ inboxPort = Number.parseInt(options.inboxPort, 10);
402
+ if (!Number.isInteger(inboxPort) || inboxPort <= 0 || inboxPort > 65_534) {
403
+ io.writeErr("--inbox-port must be an integer between 1 and 65534.\n");
404
+ io.setExitCode(2);
405
+ return;
406
+ }
407
+ if (inboxPort === port) {
408
+ io.writeErr("--inbox-port must differ from --port (the capture listener is loopback-only; the inbox listener is not).\n");
409
+ io.setExitCode(2);
410
+ return;
411
+ }
412
+ }
413
+ await runCommsCatchHost({
414
+ port,
415
+ dir: options.dir,
416
+ ...(options.token ? { token: options.token } : {}),
417
+ ...(inboxPort === undefined ? {} : { inboxPort }),
418
+ ...(options.recipient && options.recipient.length > 0 ? { recipients: options.recipient } : {})
419
+ }, io);
420
+ });
421
+ }
375
422
  function registerReclaimCommand(parent, io) {
376
423
  parent
377
424
  .command("reclaim")