humanish 0.30.0 → 0.32.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/README.md CHANGED
@@ -262,6 +262,18 @@ you own (a Vercel preview, staging), use an `app-url` subject with
262
262
  only declared subject env names do. `humanish init` scaffolds an example at
263
263
  `humanish/labs/cua-browser.yaml`.
264
264
 
265
+ **Off-app email/SMS verification (`comms`).** When a flow is gated behind an email
266
+ or SMS the app itself sends — a signup verification link, a one-time code, a magic
267
+ link — add a `comms:` block to the lab. Humanish redirects the app's email-API sends
268
+ (via one adopter-named env var, e.g. `RESEND_API_URL`) into a catch **inside** the
269
+ sandbox, so nothing leaves the machine; the persona then opens a synthetic inbox to
270
+ read and click through, and the run bundle gets a digest-only
271
+ `humanish.comms-thread.v1` artifact (from/to/subject/link digests + an OTP count —
272
+ no raw address, link, or code persists). It works on the single-lane clone/local-tree
273
+ route and the concurrent shared-world route, and is vendor-neutral (Resend/SendGrid
274
+ shaped, or a custom profile). See `docs/contracts/schemas.md` for the full `comms:`
275
+ shape.
276
+
265
277
  **Screenshots are full-fidelity by default.** Run bundles live in gitignored
266
278
  `.humanish/`, so the Observer shows exactly what the persona saw — the point of
267
279
  simming your own app. Set `policies.redactScreenshots: true` to persist blurred
@@ -4,7 +4,7 @@ export interface CommsAddress {
4
4
  channel: CommsChannelKind;
5
5
  /** Which lane owns this inbox. */
6
6
  actorId: string;
7
- /** Runtime-only raw address, e.g. patient-07@example.test | +15550137. */
7
+ /** Runtime-only raw address, e.g. user-07@example.test | +15550137. */
8
8
  value: string;
9
9
  /** sha256-short(value) — the only form persisted (redaction.digestText). */
10
10
  digest: string;
@@ -612,6 +612,14 @@ export declare function captureDesktopBrowserGeometry(args: {
612
612
  viewport?: RunDesktopGeometry["viewport"];
613
613
  warnings: string[];
614
614
  }>;
615
+ /**
616
+ * A goal_satisfied lane counts as a self-reported blocker ONLY when its final narrative contradicts
617
+ * the goal AND the run's own stop predicate did NOT fire. A matched stopWhen is independent,
618
+ * structured completion evidence, so it overrides a text scan of the free-form narrative — which can
619
+ * otherwise trip on the subject app's OWN quoted copy (e.g. a relayed "cannot be undone" banner).
620
+ * Returns the offending reason, or undefined when the lane is a clean pass. Exported for testing.
621
+ */
622
+ export declare function resolveSelfReportedBlocker(session: CuaLoopResult | undefined): string | undefined;
615
623
  /**
616
624
  * Run ONE E2B desktop lane end-to-end: create the sandbox (per-lane metadata + the lane's device
617
625
  * resolution), prepareDesktop, verify geometry, (clone+serve+seed the subject per lane), open the
@@ -981,7 +981,7 @@ async function startDesktopStream(desktop, browserWindowId) {
981
981
  }
982
982
  }
983
983
  function completionReasonContradictsGoal(reason) {
984
- const text = stripNegatedNonBlockerPhrases(reason.toLowerCase());
984
+ const text = stripQuotedSpans(stripNegatedNonBlockerPhrases(reason.toLowerCase()));
985
985
  return /\b(can'?t|cannot|could not|unable|blocked|blocker|failed|invalid|not set)\b/.test(text)
986
986
  || /\b(shows|showing|hit|encountered|returned|got)\b.{0,80}\berror\b/.test(text)
987
987
  || /\berror[:.]/.test(text)
@@ -993,11 +993,37 @@ function stripNegatedNonBlockerPhrases(text) {
993
993
  .replace(/\bwithout\s+(?:a\s+|any\s+)?(?:real\s+|remaining\s+|actual\s+)?(?:blocker|blockers|blocking issue|blocking issues|error|errors|failure|failures)\b/g, "")
994
994
  .replace(/\bnot\s+(?:blocked|a blocker|an error|failed)\b/g, "");
995
995
  }
996
+ /**
997
+ * Remove double-quoted spans and markdown blockquote lines before the blocker scan, so a persona
998
+ * that faithfully QUOTES the subject app's own copy (e.g. a banner reading "cannot be undone") is
999
+ * not misread as the actor reporting its OWN blocker. Only double quotes (straight and smart) and
1000
+ * `>` blockquotes are stripped — never single quotes, which would mangle contractions like `can't`.
1001
+ */
1002
+ function stripQuotedSpans(text) {
1003
+ return text
1004
+ .replace(/"[^"]*"/g, " ")
1005
+ .replace(/“[^”]*”/g, " ")
1006
+ .replace(/^\s*>.*$/gm, " ");
1007
+ }
996
1008
  function traceHasStopWhenMatch(session) {
997
1009
  return session.trace.items.some((item) => item.kind === "notice"
998
1010
  && item.status === "matched"
999
1011
  && item.title.startsWith("stopWhen matched"));
1000
1012
  }
1013
+ /**
1014
+ * A goal_satisfied lane counts as a self-reported blocker ONLY when its final narrative contradicts
1015
+ * the goal AND the run's own stop predicate did NOT fire. A matched stopWhen is independent,
1016
+ * structured completion evidence, so it overrides a text scan of the free-form narrative — which can
1017
+ * otherwise trip on the subject app's OWN quoted copy (e.g. a relayed "cannot be undone" banner).
1018
+ * Returns the offending reason, or undefined when the lane is a clean pass. Exported for testing.
1019
+ */
1020
+ export function resolveSelfReportedBlocker(session) {
1021
+ return session?.completionReason === "goal_satisfied"
1022
+ && completionReasonContradictsGoal(session.reason)
1023
+ && !traceHasStopWhenMatch(session)
1024
+ ? session.reason
1025
+ : undefined;
1026
+ }
1001
1027
  /**
1002
1028
  * Run ONE E2B desktop lane end-to-end: create the sandbox (per-lane metadata + the lane's device
1003
1029
  * resolution), prepareDesktop, verify geometry, (clone+serve+seed the subject per lane), open the
@@ -1468,9 +1494,7 @@ export async function runCuaLane(spec, deps) {
1468
1494
  if (noEngagement) {
1469
1495
  warnings.push("Actor returned goal_satisfied with ZERO actions and ZERO messages — it likely saw a blank or still-loading screen and stopped without engaging. NOT counted as a pass. Check the screenshot; raise execution.timeoutMs or confirm the subject painted before the first turn.");
1470
1496
  }
1471
- const blockerReason = session?.completionReason === "goal_satisfied" && completionReasonContradictsGoal(session.reason)
1472
- ? session.reason
1473
- : undefined;
1497
+ const blockerReason = resolveSelfReportedBlocker(session);
1474
1498
  const selfReportedBlocker = blockerReason !== undefined;
1475
1499
  if (selfReportedBlocker) {
1476
1500
  warnings.push(`Actor returned goal_satisfied while its final message describes a blocker or asks for missing instructions — NOT counted as a pass: ${redactText(deps.scrubKnownValues(blockerReason))}`);
@@ -1538,9 +1562,7 @@ async function runInProcessLane(spec, deps) {
1538
1562
  if (noEngagement) {
1539
1563
  warnings.push("Actor returned goal_satisfied with ZERO actions and ZERO messages — it likely saw a blank or still-loading screen and stopped without engaging. NOT counted as a pass. Check the screenshot; raise execution.timeoutMs or confirm the subject painted before the first turn.");
1540
1564
  }
1541
- const blockerReason = session?.completionReason === "goal_satisfied" && completionReasonContradictsGoal(session.reason)
1542
- ? session.reason
1543
- : undefined;
1565
+ const blockerReason = resolveSelfReportedBlocker(session);
1544
1566
  const selfReportedBlocker = blockerReason !== undefined;
1545
1567
  if (selfReportedBlocker) {
1546
1568
  warnings.push(`Actor returned goal_satisfied while its final message describes a blocker or asks for missing instructions — NOT counted as a pass: ${blockerReason}`);