humanish 0.45.0 → 0.47.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.
@@ -579,6 +579,7 @@ function blockedLaneOutcome(spec, reason) {
579
579
  skippedReason: reason,
580
580
  noEngagement: false,
581
581
  selfReportedBlocker: false,
582
+ reportedFriction: false,
582
583
  harnessError: false
583
584
  };
584
585
  }
@@ -1073,13 +1074,47 @@ async function startDesktopStream(desktop, browserWindowId) {
1073
1074
  await desktop.stream.start({ requireAuth: true });
1074
1075
  }
1075
1076
  }
1076
- function completionReasonContradictsGoal(reason) {
1077
- const text = stripQuotedSpans(stripNegatedNonBlockerPhrases(reason.toLowerCase()));
1077
+ function hasBlockerLanguage(text) {
1078
1078
  return /\b(can'?t|cannot|could not|unable|blocked|blocker|failed|invalid|not set)\b/.test(text)
1079
1079
  || /\b(shows|showing|hit|encountered|returned|got)\b.{0,80}\berror\b/.test(text)
1080
1080
  || /\berror[:.]/.test(text)
1081
1081
  || /what would you like me to do|please tell me|need (the )?(task|credentials|instructions)/.test(text);
1082
1082
  }
1083
+ /** The friction scan (inclusive): does the narrative report ANY blocker-shaped language,
1084
+ * resolved or not? Feeds the participants `reportedFriction` tally and feedback candidates. */
1085
+ function completionReasonContradictsGoal(reason) {
1086
+ return hasBlockerLanguage(stripQuotedSpans(stripNegatedNonBlockerPhrases(reason.toLowerCase())));
1087
+ }
1088
+ /** The verdict scan (strict): like the friction scan, but resolved-arc segments are stripped
1089
+ * first — failure narration the participant itself reports as overcome is friction on the
1090
+ * road, not a blocker at the destination (#453). */
1091
+ function completionReasonBlocksVerdict(reason) {
1092
+ return hasBlockerLanguage(stripResolvedArcSegments(stripQuotedSpans(stripNegatedNonBlockerPhrases(reason.toLowerCase()))));
1093
+ }
1094
+ // A failure segment counts as a resolved arc when the recovery is self-reported either in the
1095
+ // SAME segment ("the import failed but then went through") or — the common report shape — in the
1096
+ // immediately FOLLOWING segment as a retry/alternative that succeeded ("my first import failed
1097
+ // with a parser error. A simpler SQL import succeeded."). The lookahead demands the retry flavor
1098
+ // on purpose: unrelated praise ("Separately, the search box worked") must never launder an
1099
+ // unresolved failure. "Login failed so I gave up" has no recovery anywhere and stays a blocker.
1100
+ // (#453 — the run-1 false negative: a defect report after demonstrated success failed the lane,
1101
+ // an incentive inversion against exactly the participant behavior a study wants most.)
1102
+ const RESOLUTION_TERMS = /\b(succeed(?:ed|s)?|success(?:ful|fully)?|worked|works around|then worked|now works?|resolved|fixed|recovered|got it working|went through)\b/;
1103
+ const RETRY_RESOLUTION = /\b(simpler|simplified|retry(?:ing)?|retried|second (?:attempt|try)|another (?:attempt|try|approach)|different (?:approach|way|route)|instead|then|eventually|after that)\b[^.!?\n]{0,80}\b(succeed(?:ed|s)?|success(?:ful|fully)?|worked|went through|completed|passed)\b/;
1104
+ /** Drop sentence/bullet segments whose failure language is part of a self-reported RESOLVED arc. */
1105
+ function stripResolvedArcSegments(text) {
1106
+ const segments = text.split(/(?<=[.!?])\s+|\n+/);
1107
+ return segments
1108
+ .filter((segment, index) => {
1109
+ if (!hasBlockerLanguage(segment))
1110
+ return true;
1111
+ if (RESOLUTION_TERMS.test(segment))
1112
+ return false;
1113
+ const next = segments[index + 1];
1114
+ return !(next !== undefined && RETRY_RESOLUTION.test(next));
1115
+ })
1116
+ .join(" ");
1117
+ }
1083
1118
  function stripNegatedNonBlockerPhrases(text) {
1084
1119
  return text
1085
1120
  .replace(/\bno\s+(?:real\s+|remaining\s+|actual\s+)?(?:blocker|blockers|blocking issue|blocking issues|error|errors|failure|failures)\s+(?:was\s+|were\s+)?(?:encountered|observed|found|hit|seen|reported|detected)\b/g, "")
@@ -1108,9 +1143,23 @@ function traceHasStopWhenMatch(session) {
1108
1143
  * the goal AND the run's own stop predicate did NOT fire. A matched stopWhen is independent,
1109
1144
  * structured completion evidence, so it overrides a text scan of the free-form narrative — which can
1110
1145
  * otherwise trip on the subject app's OWN quoted copy (e.g. a relayed "cannot be undone" banner).
1111
- * Returns the offending reason, or undefined when the lane is a clean pass. Exported for testing.
1146
+ * Resolved-arc segments never block the verdict (#453). Returns the offending reason, or undefined
1147
+ * when the lane is a clean pass. Exported for testing.
1112
1148
  */
1113
1149
  export function resolveSelfReportedBlocker(session) {
1150
+ return session?.completionReason === "goal_satisfied"
1151
+ && completionReasonBlocksVerdict(session.reason)
1152
+ && !traceHasStopWhenMatch(session)
1153
+ ? session.reason
1154
+ : undefined;
1155
+ }
1156
+ /**
1157
+ * The friction read of the same narrative (#453): everything the verdict scan counts PLUS
1158
+ * resolved arcs — a participant who hit a wall, got past it, and said so has reported friction
1159
+ * worth a tally count and a feedback candidate, without costing the lane its pass. Same
1160
+ * quoted-copy and stopWhen discipline as the verdict resolver. Exported for testing.
1161
+ */
1162
+ export function resolveSelfReportedFriction(session) {
1114
1163
  return session?.completionReason === "goal_satisfied"
1115
1164
  && completionReasonContradictsGoal(session.reason)
1116
1165
  && !traceHasStopWhenMatch(session)
@@ -1498,7 +1547,10 @@ export async function runCuaLane(spec, deps) {
1498
1547
  }),
1499
1548
  ...(deps.onObservedUrl === undefined ? {} : { onObservedUrl: deps.onObservedUrl }),
1500
1549
  ...(deps.onMessage === undefined ? {} : { onMessage: deps.onMessage }),
1501
- ...(deps.onScreenshot === undefined ? {} : { onScreenshot: deps.onScreenshot })
1550
+ ...(deps.onScreenshot === undefined ? {} : { onScreenshot: deps.onScreenshot }),
1551
+ ...(deps.onTrace === undefined
1552
+ ? {}
1553
+ : { onTrace: (items) => deps.onTrace?.(spec.laneId, items) })
1502
1554
  };
1503
1555
  session = await deps.runSession(sessionOptions);
1504
1556
  }
@@ -1663,6 +1715,7 @@ export async function runCuaLane(spec, deps) {
1663
1715
  }
1664
1716
  const blockerReason = resolveSelfReportedBlocker(session);
1665
1717
  const selfReportedBlocker = blockerReason !== undefined;
1718
+ const reportedFriction = resolveSelfReportedFriction(session) !== undefined;
1666
1719
  if (selfReportedBlocker) {
1667
1720
  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))}`);
1668
1721
  }
@@ -1684,6 +1737,7 @@ export async function runCuaLane(spec, deps) {
1684
1737
  warnings,
1685
1738
  noEngagement,
1686
1739
  selfReportedBlocker,
1740
+ reportedFriction,
1687
1741
  harnessError,
1688
1742
  ...(failureCode === undefined ? {} : { failureCode }),
1689
1743
  ...(commsArtifactPath === undefined ? {} : { commsArtifactPath })
@@ -1732,6 +1786,7 @@ async function runInProcessLane(spec, deps) {
1732
1786
  }
1733
1787
  const blockerReason = resolveSelfReportedBlocker(session);
1734
1788
  const selfReportedBlocker = blockerReason !== undefined;
1789
+ const reportedFriction = resolveSelfReportedFriction(session) !== undefined;
1735
1790
  if (selfReportedBlocker) {
1736
1791
  warnings.push(`Actor returned goal_satisfied while its final message describes a blocker or asks for missing instructions — NOT counted as a pass: ${blockerReason}`);
1737
1792
  }
@@ -1747,6 +1802,7 @@ async function runInProcessLane(spec, deps) {
1747
1802
  warnings,
1748
1803
  noEngagement,
1749
1804
  selfReportedBlocker,
1805
+ reportedFriction,
1750
1806
  harnessError: sessionError !== undefined || session?.completionReason === "harness_error",
1751
1807
  entryKind: "local-app"
1752
1808
  };
@@ -1821,6 +1877,7 @@ export async function runCuaLanes(laneSpecs, deps, concurrency, runLane = runCua
1821
1877
  warnings: [],
1822
1878
  noEngagement: false,
1823
1879
  selfReportedBlocker: false,
1880
+ reportedFriction: false,
1824
1881
  harnessError: true,
1825
1882
  sessionError: `lane runner threw outside the session guard: ${detail}`
1826
1883
  };
@@ -2246,7 +2303,13 @@ export async function runCuaActorLab(options) {
2246
2303
  return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", `local-tree packing failed: ${redactText(scrubKnownValues(toErrorMessage(error)))}`, descriptor.id);
2247
2304
  }
2248
2305
  }
2306
+ // Live-trace flush seam (#441): assigned by the attached-Observer block below when a live
2307
+ // run has an in-progress bundle to grow; lanes call it through deps.onTrace. Declared here
2308
+ // (before deps) so deps can reference it as a stable indirection.
2309
+ let flushLiveTrace;
2310
+ let stopLiveFlush;
2249
2311
  const deps = {
2312
+ onTrace: (laneId, items) => flushLiveTrace?.(laneId, items),
2250
2313
  config,
2251
2314
  descriptor,
2252
2315
  appUrl,
@@ -2335,6 +2398,79 @@ export async function runCuaActorLab(options) {
2335
2398
  "Live CUA Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
2336
2399
  ]);
2337
2400
  await options.onObserverReady(liveObserver);
2401
+ // Incremental live flush (#441): as each lane's loop reports its recorded-so-far items,
2402
+ // rewrite the in-progress bundle with per-stream `liveActor` partials so the attached
2403
+ // Observer's 5s poll sees the timeline grow. Throttled (one write per interval, trailing
2404
+ // write guaranteed), serialized (never two writers), and CLOSED before the final artifact
2405
+ // write so a stale flush can never resurrect the in-progress bundle. A flush failure is
2406
+ // swallowed: mid-run observability must never break the run itself.
2407
+ const streamIdByLane = new Map(laneSpecs.map((spec) => [spec.laneId, spec.streamId]));
2408
+ const liveItemsByStream = new Map();
2409
+ let flushWriting;
2410
+ let flushDirty = false;
2411
+ let flushClosed = false;
2412
+ let flushTimer;
2413
+ let lastFlushAtMs = 0;
2414
+ const FLUSH_MIN_INTERVAL_MS = 2_000;
2415
+ const flushNow = async () => {
2416
+ while (flushDirty && !flushClosed) {
2417
+ flushDirty = false;
2418
+ lastFlushAtMs = Date.now();
2419
+ const updatedAt = new Date(lastFlushAtMs).toISOString();
2420
+ const patched = {
2421
+ ...inProgressBundle,
2422
+ streams: inProgressBundle.streams.map((stream) => {
2423
+ const liveItems = liveItemsByStream.get(stream.id);
2424
+ return liveItems === undefined
2425
+ ? stream
2426
+ : { ...stream, liveActor: { schema: "humanish.live-actor.v1", updatedAt, items: [...liveItems] } };
2427
+ })
2428
+ };
2429
+ try {
2430
+ await writeCuaRunArtifacts(patched, createdAt, runPaths);
2431
+ }
2432
+ catch {
2433
+ // Swallowed by design; the final write is the evidence of record.
2434
+ }
2435
+ }
2436
+ flushWriting = undefined;
2437
+ };
2438
+ const scheduleFlush = () => {
2439
+ if (flushClosed || flushWriting !== undefined)
2440
+ return;
2441
+ const sinceMs = Date.now() - lastFlushAtMs;
2442
+ if (sinceMs >= FLUSH_MIN_INTERVAL_MS) {
2443
+ flushWriting = flushNow();
2444
+ return;
2445
+ }
2446
+ if (flushTimer === undefined) {
2447
+ flushTimer = setTimeout(() => {
2448
+ flushTimer = undefined;
2449
+ scheduleFlush();
2450
+ }, FLUSH_MIN_INTERVAL_MS - sinceMs);
2451
+ flushTimer.unref?.();
2452
+ }
2453
+ };
2454
+ flushLiveTrace = (laneId, items) => {
2455
+ // An empty snapshot (the initial observation on a frameless route) carries no
2456
+ // evidence worth a disk write; the first real item triggers the first flush.
2457
+ if (items.length === 0)
2458
+ return;
2459
+ const streamId = streamIdByLane.get(laneId);
2460
+ if (streamId === undefined)
2461
+ return;
2462
+ liveItemsByStream.set(streamId, items.slice());
2463
+ flushDirty = true;
2464
+ scheduleFlush();
2465
+ };
2466
+ stopLiveFlush = async () => {
2467
+ flushClosed = true;
2468
+ if (flushTimer !== undefined) {
2469
+ clearTimeout(flushTimer);
2470
+ flushTimer = undefined;
2471
+ }
2472
+ await flushWriting;
2473
+ };
2338
2474
  }
2339
2475
  // Run lanes (dry-run runs none). In-process is always one lane.
2340
2476
  let outcomes;
@@ -2352,6 +2488,10 @@ export async function runCuaActorLab(options) {
2352
2488
  failFastReason = ran.failFastReason;
2353
2489
  }
2354
2490
  }
2491
+ // Close the live flush BEFORE any final artifact work: no new flush may start, and an
2492
+ // in-flight one is awaited, so the final bundle write can never race a stale in-progress
2493
+ // rewrite (which would resurrect `liveActor` after completion).
2494
+ await stopLiveFlush?.();
2355
2495
  const externalCommsWarnings = [];
2356
2496
  // Adopter-hosted drain (#380): once per RUN, after every lane finished — the catch is one
2357
2497
  // shared external endpoint, not a per-sandbox file. Same routing and digest-only artifact as
@@ -3172,7 +3312,7 @@ export function participantFeedbackCandidates(args) {
3172
3312
  const session = lane.session;
3173
3313
  if (session === undefined)
3174
3314
  continue;
3175
- const friction = resolveSelfReportedBlocker(session);
3315
+ const friction = resolveSelfReportedFriction(session);
3176
3316
  const abandoned = session.status === "abandoned";
3177
3317
  if (friction === undefined && !abandoned)
3178
3318
  continue;
@@ -3873,7 +4013,7 @@ export function buildCuaFanoutBundle(args) {
3873
4013
  ? tallyParticipantOutcomes(terminalOutcomes.map((outcome) => outcome.session.status),
3874
4014
  // A participant who reached the goal AND told you the road there was broken is the most
3875
4015
  // useful result a study produces; reporting only the outcome would bury it.
3876
- terminalOutcomes.map((outcome) => outcome.selfReportedBlocker === true))
4016
+ terminalOutcomes.map((outcome) => outcome.reportedFriction === true))
3877
4017
  : undefined;
3878
4018
  // The study funnel: per-task completion rates across every session that measured one. This is
3879
4019
  // "where did people get stuck" as data, next to WHO got stuck (participants) above.