harnery 0.37.0 → 0.38.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 (184) hide show
  1. package/dist/commander.d.ts +9 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +7 -2
  4. package/dist/commands/admission.d.ts +21 -0
  5. package/dist/commands/admission.d.ts.map +1 -0
  6. package/dist/commands/admission.js +565 -0
  7. package/dist/commands/agents.d.ts +7 -0
  8. package/dist/commands/agents.d.ts.map +1 -1
  9. package/dist/commands/agents.js +61 -1
  10. package/dist/commands/artifacts.d.ts.map +1 -1
  11. package/dist/commands/artifacts.js +48 -2
  12. package/dist/commands/browse-ai.d.ts +2 -2
  13. package/dist/commands/browse-ai.d.ts.map +1 -1
  14. package/dist/commands/browse-ai.js +6 -4
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +349 -21
  17. package/dist/commands/fetch.js +1 -0
  18. package/dist/commands/qa-record.d.ts +144 -0
  19. package/dist/commands/qa-record.d.ts.map +1 -0
  20. package/dist/commands/qa-record.js +0 -0
  21. package/dist/commands/qa-run.d.ts +7 -3
  22. package/dist/commands/qa-run.d.ts.map +1 -1
  23. package/dist/commands/qa-run.js +268 -13
  24. package/dist/commands/qa-status.d.ts +71 -0
  25. package/dist/commands/qa-status.d.ts.map +1 -0
  26. package/dist/commands/qa-status.js +490 -0
  27. package/dist/commands/qa-verify.d.ts +40 -0
  28. package/dist/commands/qa-verify.d.ts.map +1 -0
  29. package/dist/commands/qa-verify.js +180 -0
  30. package/dist/commands/review-pack.d.ts +4 -0
  31. package/dist/commands/review-pack.d.ts.map +1 -0
  32. package/dist/commands/review-pack.js +1001 -0
  33. package/dist/core/agents/qa-signal.d.ts +111 -0
  34. package/dist/core/agents/qa-signal.d.ts.map +1 -0
  35. package/dist/core/agents/qa-signal.js +231 -0
  36. package/dist/core/agents/session-name-display.d.ts +20 -5
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +67 -7
  39. package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
  40. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  41. package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
  42. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  43. package/dist/core/agents/state/heartbeat-writer.js +17 -0
  44. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  45. package/dist/core/agents/state/live-coordination-view.js +1 -0
  46. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  47. package/dist/core/artifacts/constants.d.ts +1 -1
  48. package/dist/core/artifacts/constants.js +1 -1
  49. package/dist/core/artifacts/index.d.ts +57 -6
  50. package/dist/core/artifacts/index.d.ts.map +1 -1
  51. package/dist/core/artifacts/index.js +265 -10
  52. package/dist/core/config.d.ts +8 -0
  53. package/dist/core/config.d.ts.map +1 -1
  54. package/dist/core/config.js +16 -0
  55. package/dist/core/diagnostics/bundle.d.ts +16 -0
  56. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  57. package/dist/core/diagnostics/bundle.js +101 -7
  58. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  59. package/dist/core/events/v3/bootstrap.js +10 -0
  60. package/dist/core/events/v3/coordination-view.d.ts +3 -0
  61. package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
  62. package/dist/core/events/v3/coordination-view.js +69 -8
  63. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  64. package/dist/core/events/v3/producers/intake.js +9 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts +23 -0
  66. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  67. package/dist/core/events/v3/producers/recorder.js +264 -18
  68. package/dist/core/hooks/cli.js +120 -27
  69. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  70. package/dist/core/hooks/resolve/transcript.js +10 -3
  71. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  72. package/dist/core/hooks/session-name-presence.js +4 -1
  73. package/dist/core/qa-artifacts.d.ts +20 -0
  74. package/dist/core/qa-artifacts.d.ts.map +1 -0
  75. package/dist/core/qa-artifacts.js +110 -0
  76. package/dist/core/resources/contract.d.ts +6 -0
  77. package/dist/core/resources/contract.d.ts.map +1 -1
  78. package/dist/core/resources/sampler.d.ts +7 -0
  79. package/dist/core/resources/sampler.d.ts.map +1 -1
  80. package/dist/core/resources/sampler.js +106 -5
  81. package/dist/lib/admission.d.ts +71 -0
  82. package/dist/lib/admission.d.ts.map +1 -0
  83. package/dist/lib/admission.js +264 -0
  84. package/dist/lib/agent-browser/client.d.ts +1 -1
  85. package/dist/lib/agent-browser/client.d.ts.map +1 -1
  86. package/dist/lib/agent-browser/client.js +1 -5
  87. package/dist/lib/browser/capture-fidelity.d.ts +39 -0
  88. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
  89. package/dist/lib/browser/capture-fidelity.js +84 -0
  90. package/dist/lib/browser/client.d.ts +41 -1
  91. package/dist/lib/browser/client.d.ts.map +1 -1
  92. package/dist/lib/browser/client.js +167 -9
  93. package/dist/lib/browser/critique.d.ts +38 -1
  94. package/dist/lib/browser/critique.d.ts.map +1 -1
  95. package/dist/lib/browser/critique.js +34 -6
  96. package/dist/lib/browser/index.d.ts +4 -2
  97. package/dist/lib/browser/index.d.ts.map +1 -1
  98. package/dist/lib/browser/index.js +2 -0
  99. package/dist/lib/browser/page-review-judge.d.ts +64 -0
  100. package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
  101. package/dist/lib/browser/page-review-judge.js +270 -0
  102. package/dist/lib/browser/page-review-pack.d.ts +613 -0
  103. package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
  104. package/dist/lib/browser/page-review-pack.js +1751 -0
  105. package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
  106. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  107. package/dist/lib/browser/qa-run-contracts.js +136 -1
  108. package/dist/lib/browser/qa-run.d.ts +100 -10
  109. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  110. package/dist/lib/browser/qa-run.js +768 -169
  111. package/dist/lib/browser/request-diagnostics.d.ts +13 -0
  112. package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
  113. package/dist/lib/browser/request-diagnostics.js +18 -0
  114. package/dist/lib/browser/tiling.d.ts +19 -0
  115. package/dist/lib/browser/tiling.d.ts.map +1 -1
  116. package/dist/lib/browser/tiling.js +28 -0
  117. package/dist/lib/cookies/client.d.ts +9 -0
  118. package/dist/lib/cookies/client.d.ts.map +1 -1
  119. package/dist/lib/cookies/client.js +197 -44
  120. package/dist/lib/cookies/extra.d.ts +18 -0
  121. package/dist/lib/cookies/extra.d.ts.map +1 -0
  122. package/dist/lib/cookies/extra.js +14 -0
  123. package/dist/lib/cookies/index.d.ts +2 -1
  124. package/dist/lib/cookies/index.d.ts.map +1 -1
  125. package/dist/lib/cookies/index.js +2 -1
  126. package/dist/lib/durable-job.d.ts +124 -0
  127. package/dist/lib/durable-job.d.ts.map +1 -0
  128. package/dist/lib/durable-job.js +296 -0
  129. package/dist/lib/http/client.d.ts +7 -1
  130. package/dist/lib/http/client.d.ts.map +1 -1
  131. package/dist/lib/http/client.js +2 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +5 -2
  134. package/package.json +8 -2
  135. package/src/commander.ts +50 -2
  136. package/src/commands/admission.ts +699 -0
  137. package/src/commands/agents.ts +87 -1
  138. package/src/commands/artifacts.ts +97 -21
  139. package/src/commands/browse-ai.ts +10 -5
  140. package/src/commands/browse.ts +481 -20
  141. package/src/commands/fetch.ts +1 -0
  142. package/src/commands/qa-record.ts +682 -0
  143. package/src/commands/qa-run.ts +335 -16
  144. package/src/commands/qa-status.ts +608 -0
  145. package/src/commands/qa-verify.ts +238 -0
  146. package/src/commands/review-pack.ts +1281 -0
  147. package/src/core/agents/qa-signal.ts +261 -0
  148. package/src/core/agents/session-name-display.ts +78 -7
  149. package/src/core/agents/state/heartbeat-reader.ts +7 -0
  150. package/src/core/agents/state/heartbeat-writer.ts +23 -0
  151. package/src/core/agents/state/live-coordination-view.ts +1 -0
  152. package/src/core/agents/state/live-coordination-writer.ts +5 -0
  153. package/src/core/artifacts/constants.ts +1 -1
  154. package/src/core/artifacts/index.ts +370 -21
  155. package/src/core/config.ts +23 -0
  156. package/src/core/diagnostics/bundle.ts +119 -11
  157. package/src/core/events/v3/bootstrap.ts +10 -0
  158. package/src/core/events/v3/coordination-view.ts +100 -11
  159. package/src/core/events/v3/producers/intake.ts +9 -2
  160. package/src/core/events/v3/producers/recorder.ts +312 -18
  161. package/src/core/hooks/cli.ts +140 -32
  162. package/src/core/hooks/resolve/transcript.ts +10 -3
  163. package/src/core/hooks/session-name-presence.ts +6 -1
  164. package/src/core/qa-artifacts.ts +126 -0
  165. package/src/core/resources/contract.ts +7 -0
  166. package/src/core/resources/sampler.ts +138 -6
  167. package/src/lib/admission.ts +347 -0
  168. package/src/lib/agent-browser/client.ts +2 -10
  169. package/src/lib/browser/capture-fidelity.ts +98 -0
  170. package/src/lib/browser/client.ts +206 -10
  171. package/src/lib/browser/critique.ts +62 -7
  172. package/src/lib/browser/index.ts +36 -0
  173. package/src/lib/browser/page-review-judge.ts +360 -0
  174. package/src/lib/browser/page-review-pack.ts +2384 -0
  175. package/src/lib/browser/qa-run-contracts.ts +366 -3
  176. package/src/lib/browser/qa-run.ts +868 -190
  177. package/src/lib/browser/request-diagnostics.ts +27 -0
  178. package/src/lib/browser/tiling.ts +32 -0
  179. package/src/lib/cookies/client.ts +228 -42
  180. package/src/lib/cookies/extra.ts +28 -0
  181. package/src/lib/cookies/index.ts +2 -0
  182. package/src/lib/durable-job.ts +407 -0
  183. package/src/lib/http/client.ts +13 -1
  184. package/src/lib/instructions/templates.ts +5 -2
@@ -38,13 +38,20 @@ import { evaluateStopHook, STOP_REMEDIATION_MARKER } from "../agents/rules/stop-
38
38
  import {
39
39
  assistantTextStartsWithSessionNameBlock,
40
40
  isSessionNameRemediationCommand,
41
+ matchSessionNameDisplay,
42
+ sessionNameDisplayAcceptedNames,
41
43
  sessionNameDisplayInstruction,
42
44
  sessionNameDisplayPending,
43
45
  sessionNameDisplayRecoveryInstruction,
44
46
  toolResponseMintedSessionName,
45
47
  } from "../agents/session-name-display.ts";
46
48
  import type { Heartbeat } from "../agents/state/heartbeat-reader.ts";
47
- import { readHeartbeat, stampSessionNameSeen } from "../agents/state/heartbeat-writer.ts";
49
+ import {
50
+ readHeartbeat,
51
+ setAssignedNameCache,
52
+ stampSessionNameRequested,
53
+ stampSessionNameSeen,
54
+ } from "../agents/state/heartbeat-writer.ts";
48
55
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
49
56
  import { ensureLiveCoordinationHeartbeat } from "../agents/state/live-coordination-writer.ts";
50
57
  import { assignName } from "../agents/state/names.ts";
@@ -113,6 +120,7 @@ import {
113
120
  import { adapterPidFromEnv, parsePsChainLine, selectAnchorPid } from "./resolve/anchor.ts";
114
121
  import { extractIntentComment, resolveIntent } from "./resolve/intent.ts";
115
122
  import { resolveOwner } from "./resolve/owner.ts";
123
+ import type { SessionNameDisplayInspection } from "./resolve/transcript.ts";
116
124
  import {
117
125
  inspectSessionNameDisplayImmediately,
118
126
  scanAssistantStatusBoxPresent,
@@ -245,6 +253,31 @@ function assignNameInProcess(
245
253
  }
246
254
  }
247
255
 
256
+ /** Mid-flight sessions can emit tools before SessionStart assigns a pool name.
257
+ * Stamp the disposable cache so Codec does not render the 8-character id. */
258
+ function ensureSessionDisplayName(
259
+ coordRoot: string,
260
+ instanceId: string,
261
+ sessionId: string,
262
+ adapter: Adapter,
263
+ ): void {
264
+ if (coordEnv("WORKFLOW_CHILD") === "1") return;
265
+ const row =
266
+ readLiveCoordinationRow(coordRoot, instanceId) ?? readHeartbeat(coordRoot, instanceId);
267
+ if (row?.kind === "subagent" || row?.kind === "transient" || row?.workflow_run_id) return;
268
+ if (row?.name?.trim()) return;
269
+ const assigned = assignNameInProcess(coordRoot, instanceId, "session");
270
+ if (!assigned?.name) return;
271
+ if (!readHeartbeat(coordRoot, instanceId)) {
272
+ try {
273
+ ensureLiveCoordinationHeartbeat(coordRoot, instanceId, sessionId, adapter);
274
+ } catch {
275
+ /* cache materialization is best-effort; history still holds the name */
276
+ }
277
+ }
278
+ setAssignedNameCache(coordRoot, instanceId, assigned.name, "session");
279
+ }
280
+
248
281
  /**
249
282
  * Direct (in-process) pidmap write, avoiding the spawn overhead of going via
250
283
  * the agent-coord CLI for every session.started signal. Pid-map rows
@@ -376,13 +409,11 @@ function buildEventData(
376
409
  };
377
410
 
378
411
  case "turn.started": {
379
- // Cursor can deliver beforeSubmitPrompt before sessionStart. Ensure that
380
- // high-confidence prompt bootstrap also mints durable display identity;
381
- // a later SessionStart remains idempotent.
382
- if (
383
- ctx.adapter === "cursor" &&
384
- !readLiveCoordinationRow(ctx.coordRoot, ctx.instanceId)?.name
385
- ) {
412
+ // Mid-flight onboarding (any adapter) can observe a turn before
413
+ // SessionStart. Mint durable display identity here so a later
414
+ // SessionStart remains idempotent and Codec is not stuck on the
415
+ // 8-character native id.
416
+ if (!readLiveCoordinationRow(ctx.coordRoot, ctx.instanceId)?.name) {
386
417
  assignNameInProcess(ctx.coordRoot, ctx.instanceId, "session");
387
418
  }
388
419
  const prompt = p?.prompt ?? "";
@@ -703,13 +734,32 @@ async function main(): Promise<number> {
703
734
  appendDebug(coordRoot, { ...debugBase, skipped: "no-owner-resolved" });
704
735
  return 0;
705
736
  }
706
- const name = sessionNameDisplayPending(readLiveCoordinationRow(coordRoot, owner.instance_id));
737
+ const row = readLiveCoordinationRow(coordRoot, owner.instance_id);
738
+ const name = sessionNameDisplayPending(row);
707
739
  const text = typeof payload?.raw.text === "string" ? payload.raw.text : "";
708
- if (name && assistantTextStartsWithSessionNameBlock(text, name)) {
709
- stampSessionNameSeen(coordRoot, owner.instance_id, name);
710
- appendDebug(coordRoot, { ...debugBase, effect: "session-name-display-stamped" });
740
+ const sighting = matchSessionNameDisplay(row, text);
741
+ if (sighting) {
742
+ stampSessionNameSeen(coordRoot, owner.instance_id, sighting.pending);
743
+ appendDebug(coordRoot, {
744
+ ...debugBase,
745
+ effect: "session-name-display-stamped",
746
+ session_name_pending: sighting.pending,
747
+ ...(sighting.displayed === sighting.pending
748
+ ? {}
749
+ : { session_name_displayed: sighting.displayed, session_name_drift: true }),
750
+ });
751
+ } else if (!name) {
752
+ // Nothing was owed. Distinct from a miss: conflating the two is what made
753
+ // a latched Cursor session undiagnosable from this log.
754
+ appendDebug(coordRoot, { ...debugBase, skipped: "no-pending-session-name" });
711
755
  } else {
712
- appendDebug(coordRoot, { ...debugBase, skipped: "no-session-name-sighting" });
756
+ appendDebug(coordRoot, {
757
+ ...debugBase,
758
+ skipped: "session-name-block-absent",
759
+ session_name_pending: name,
760
+ reply_bytes: text.length,
761
+ reply_leads_with_fence: /^\s*`{3,}/.test(text),
762
+ });
713
763
  }
714
764
  if (ledgerRoute.state === "blocked") {
715
765
  appendDebug(coordRoot, {
@@ -784,6 +834,16 @@ async function main(): Promise<number> {
784
834
 
785
835
  const sessionId = payload?.session_id ?? payload?.conversation_id ?? owner.instance_id;
786
836
  const priorCoordination = readHeartbeat(coordRoot, owner.instance_id);
837
+ if (
838
+ norm.event_type !== "session.ended" &&
839
+ coordEnv("WORKFLOW_CHILD") !== "1" &&
840
+ priorCoordination?.kind !== "subagent" &&
841
+ priorCoordination?.kind !== "transient" &&
842
+ !priorCoordination?.workflow_run_id &&
843
+ !priorCoordination?.name?.trim()
844
+ ) {
845
+ assignNameInProcess(coordRoot, owner.instance_id, "session");
846
+ }
787
847
 
788
848
  const data = buildEventData(norm.event_type, {
789
849
  coordRoot,
@@ -879,6 +939,13 @@ async function main(): Promise<number> {
879
939
  logError(coordRoot, error, { phase: "epoch-coordination-restore" });
880
940
  }
881
941
  }
942
+ if (norm.event_type !== "session.ended") {
943
+ try {
944
+ ensureSessionDisplayName(coordRoot, owner.instance_id, sessionId, adapter);
945
+ } catch (error) {
946
+ logError(coordRoot, error, { phase: "session-display-name" });
947
+ }
948
+ }
882
949
  const v3EventId =
883
950
  v3Result && "event" in v3Result
884
951
  ? v3Result.event.event_id
@@ -1364,6 +1431,11 @@ async function main(): Promise<number> {
1364
1431
  try {
1365
1432
  const name = sessionNameDisplayPending(readLiveCoordinationRow(coordRoot, owner.instance_id));
1366
1433
  if (name && toolResponseMintedSessionName(payload?.tool_response, name)) {
1434
+ // Record the title before asking for it. The suggestion can still
1435
+ // change afterwards (an assigned-name rewrite, a lifecycle re-mint, a
1436
+ // rebuilt cache), and the agent must not be stranded for displaying
1437
+ // exactly what it was handed.
1438
+ stampSessionNameRequested(coordRoot, owner.instance_id, name);
1367
1439
  const { emitContext } = await import("./adapter/output.ts");
1368
1440
  emitContext(adapter, "PostToolUse", sessionNameDisplayInstruction(name));
1369
1441
  }
@@ -1440,25 +1512,7 @@ async function enforcePendingSessionNameDisplay(
1440
1512
  const command = extractBashCommand(payload?.tool_name, payload?.tool_input);
1441
1513
  if (isSessionNameRemediationCommand(command, resolveBinName(coordRoot))) return true;
1442
1514
 
1443
- const inspection =
1444
- adapter === "cursor" && payload?.agent_message !== undefined
1445
- ? {
1446
- state: assistantTextStartsWithSessionNameBlock(payload.agent_message, name)
1447
- ? ("present" as const)
1448
- : ("unavailable" as const),
1449
- }
1450
- : inspectSessionNameDisplayImmediately(
1451
- // Codex hook payloads omit transcript_path on every event, which
1452
- // left this inspection permanently unavailable and the latch never
1453
- // stamped. Discover the rollout by session id; the scan only runs
1454
- // while a name is pending, so the latch closes after one success.
1455
- payload?.transcript_path ??
1456
- (adapter === "codex"
1457
- ? discoverCodexSessionTranscript(payload?.session_id ?? instanceId)
1458
- : undefined),
1459
- name,
1460
- assistantTextStartsWithSessionNameBlock,
1461
- );
1515
+ const inspection = inspectDisplayEvidence(instanceId, adapter, payload, coordination);
1462
1516
  if (inspection.state === "present") {
1463
1517
  stampSessionNameSeen(coordRoot, instanceId, name);
1464
1518
  return true;
@@ -1473,11 +1527,65 @@ async function enforcePendingSessionNameDisplay(
1473
1527
  return true;
1474
1528
  }
1475
1529
 
1530
+ // Record the refusal. Without this the gate was the one enforcement path
1531
+ // that left no trace, so a session reporting "the gate keeps denying me"
1532
+ // could not be confirmed or refuted from the hook log.
1533
+ appendDebug(coordRoot, {
1534
+ ts: new Date().toISOString(),
1535
+ event_name: "pre-tool-use",
1536
+ adapter,
1537
+ effect: "session-name-display-denied",
1538
+ session_name_pending: name,
1539
+ tool_name: payload?.tool_name,
1540
+ });
1476
1541
  const { emitDeny } = await import("./adapter/output.ts");
1477
1542
  emitDeny(adapter, sessionNameDisplayRecoveryInstruction(name, resolveBinName(coordRoot)));
1478
1543
  return false;
1479
1544
  }
1480
1545
 
1546
+ /**
1547
+ * Evidence for the pending display, over every title whose display counts.
1548
+ *
1549
+ * Cursor supplies the current narration directly; Claude Code and Codex are
1550
+ * resolved from their JSONL transcripts. Absent is the only state that denies,
1551
+ * so a title is refused only when a readable surface positively shows some
1552
+ * other opening text for every accepted title.
1553
+ */
1554
+ function inspectDisplayEvidence(
1555
+ instanceId: string,
1556
+ adapter: Adapter,
1557
+ payload: ParsedPayload | null,
1558
+ coordination: Heartbeat | null,
1559
+ ): SessionNameDisplayInspection {
1560
+ if (adapter === "cursor" && payload?.agent_message !== undefined) {
1561
+ return matchSessionNameDisplay(coordination, payload.agent_message)
1562
+ ? { state: "present" }
1563
+ : { state: "unavailable", reason: "transcript_not_ready" };
1564
+ }
1565
+
1566
+ // Codex hook payloads omit transcript_path on every event, which left this
1567
+ // inspection permanently unavailable and the latch never stamped. Discover
1568
+ // the rollout by session id; the scan only runs while a name is pending, so
1569
+ // the latch closes after one success.
1570
+ const transcriptPath =
1571
+ payload?.transcript_path ??
1572
+ (adapter === "codex"
1573
+ ? discoverCodexSessionTranscript(payload?.session_id ?? instanceId)
1574
+ : undefined);
1575
+
1576
+ let verdict: SessionNameDisplayInspection = { state: "absent" };
1577
+ for (const candidate of sessionNameDisplayAcceptedNames(coordination)) {
1578
+ const inspection = inspectSessionNameDisplayImmediately(
1579
+ transcriptPath,
1580
+ candidate,
1581
+ assistantTextStartsWithSessionNameBlock,
1582
+ );
1583
+ if (inspection.state === "present") return inspection;
1584
+ if (inspection.state === "unavailable") verdict = inspection;
1585
+ }
1586
+ return verdict;
1587
+ }
1588
+
1481
1589
  function generationBoundHeartbeat(
1482
1590
  heartbeat: Heartbeat | null,
1483
1591
  generationId: `gen_${string}` | undefined,
@@ -167,9 +167,16 @@ export function inspectSessionNameDisplayImmediately(
167
167
  awaitingReply = false;
168
168
  }
169
169
  }
170
- return sawMintResult && awaitingReply
171
- ? { state: "unavailable", reason: "transcript_not_ready" }
172
- : { state: "absent" };
170
+ // Some adapter transcripts persist assistant messages and tool requests but
171
+ // omit tool-result rows entirely. A readable file with no mint result cannot
172
+ // establish the ordered verification boundary, so it is unavailable evidence
173
+ // rather than proof that the agent displayed the title incorrectly. Only a
174
+ // transcript that contains the mint followed by a non-matching assistant
175
+ // reply can safely deny the next tool.
176
+ if (!sawMintResult || awaitingReply) {
177
+ return { state: "unavailable", reason: "transcript_not_ready" };
178
+ }
179
+ return { state: "absent" };
173
180
  }
174
181
 
175
182
  /**
@@ -22,6 +22,7 @@
22
22
  * Never throws: coordination telemetry must not take down a hook.
23
23
  */
24
24
 
25
+ import { sessionNameDisplayAcceptedNames } from "../agents/session-name-display.ts";
25
26
  import { stampSessionNameSeen } from "../agents/state/heartbeat-writer.ts";
26
27
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
27
28
 
@@ -42,7 +43,11 @@ export function sessionNamePresence(
42
43
  if (row?.session_name_seen_for === name) {
43
44
  return { session_name_present: true, session_name_present_for: name };
44
45
  }
45
- const present = scanAssistantText(name);
46
+ // A display of the title Harnery last asked for counts here too, so Stop
47
+ // and the PreToolUse gate cannot disagree about the same reply.
48
+ const present = sessionNameDisplayAcceptedNames(row).some((candidate) =>
49
+ scanAssistantText(candidate),
50
+ );
46
51
  if (present) stampSessionNameSeen(coordRoot, instanceId, name);
47
52
  return { session_name_present: present, session_name_present_for: name };
48
53
  } catch {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Managed output workspaces for page-QA commands.
3
+ *
4
+ * An implicit qa-run/qa-record output is working evidence, not project source.
5
+ * Keep it in the bounded artifact store. Callers that pass --out-dir retain
6
+ * full control of that explicit destination.
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import type { HarneryProgramContext } from "../commander.ts";
12
+ import { monorepoRoot } from "./agents/index.ts";
13
+ import {
14
+ ARTIFACT_MANIFEST,
15
+ type ArtifactManifestV2,
16
+ artifactsRoot,
17
+ configuredArtifactRetentionDays,
18
+ createArtifact,
19
+ parseArtifactManifest,
20
+ } from "./artifacts/index.ts";
21
+
22
+ export type QaArtifactKind = "qa-run" | "qa-record" | "review-pack";
23
+
24
+ export function resolveQaRepoRoot(context?: HarneryProgramContext): string {
25
+ return resolve(context?.repoRoot ?? monorepoRoot() ?? process.cwd());
26
+ }
27
+
28
+ export function createManagedQaOutParent(
29
+ repoRoot: string,
30
+ kind: QaArtifactKind,
31
+ opts: { retentionMinutes?: number } = {},
32
+ ): string {
33
+ return createArtifact(repoRoot, {
34
+ slug: kind,
35
+ purpose:
36
+ kind === "qa-run"
37
+ ? "Page QA run evidence"
38
+ : kind === "review-pack"
39
+ ? "Page review pack"
40
+ : "Hand-recorded page QA evidence",
41
+ retentionDays: configuredArtifactRetentionDays(repoRoot),
42
+ // A standalone review-pack workspace IS the pack, so the artifact store's
43
+ // own expiry follows the pack's (90 minutes by default, ruled 2026-09-02).
44
+ ...(opts.retentionMinutes !== undefined ? { retentionMinutes: opts.retentionMinutes } : {}),
45
+ }).path;
46
+ }
47
+
48
+ /**
49
+ * Find the newest run in a valid managed QA workspace. This keeps no-path
50
+ * `qa-status` useful even though every implicit invocation gets an isolated
51
+ * artifact workspace instead of sharing one root-level `.qa-run` directory.
52
+ */
53
+ export function latestManagedQaRun(repoRoot: string): string | null {
54
+ const root = artifactsRoot(repoRoot);
55
+ if (!existsSync(root)) return null;
56
+
57
+ let workspaceNames: string[];
58
+ try {
59
+ workspaceNames = readdirSync(root).sort();
60
+ } catch {
61
+ return null;
62
+ }
63
+
64
+ let latest: { path: string; startedAtMs: number } | null = null;
65
+ for (const workspaceName of workspaceNames) {
66
+ const workspace = join(root, workspaceName);
67
+ const manifest = readQaManifest(join(workspace, ARTIFACT_MANIFEST));
68
+ if (!manifest) continue;
69
+
70
+ let runNames: string[];
71
+ try {
72
+ runNames = readdirSync(workspace)
73
+ .filter((name) => name.startsWith("run-"))
74
+ .sort();
75
+ } catch {
76
+ continue;
77
+ }
78
+ for (const runName of runNames) {
79
+ const runDir = join(workspace, runName);
80
+ const startedAtMs = readStartedAtMs(runDir);
81
+ if (startedAtMs === null) continue;
82
+ if (
83
+ latest === null ||
84
+ startedAtMs > latest.startedAtMs ||
85
+ (startedAtMs === latest.startedAtMs && runDir > latest.path)
86
+ ) {
87
+ latest = { path: runDir, startedAtMs };
88
+ }
89
+ }
90
+ }
91
+ return latest?.path ?? null;
92
+ }
93
+
94
+ function readQaManifest(path: string): ArtifactManifestV2 | null {
95
+ try {
96
+ const parsed = parseArtifactManifest(JSON.parse(readFileSync(path, "utf8")));
97
+ if (!parsed.ok) return null;
98
+ if (parsed.manifest.slug !== "qa-run" && parsed.manifest.slug !== "qa-record") return null;
99
+ return parsed.manifest;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function readStartedAtMs(runDir: string): number | null {
106
+ for (const file of ["run-status.json", "page-qa-result.json"]) {
107
+ try {
108
+ const value = JSON.parse(readFileSync(join(runDir, file), "utf8")) as Record<string, unknown>;
109
+ const run = value.run;
110
+ const startedAt =
111
+ typeof value.started_at === "string"
112
+ ? value.started_at
113
+ : run &&
114
+ typeof run === "object" &&
115
+ typeof (run as Record<string, unknown>).started_at === "string"
116
+ ? ((run as Record<string, unknown>).started_at as string)
117
+ : null;
118
+ if (!startedAt) continue;
119
+ const parsed = Date.parse(startedAt);
120
+ if (!Number.isNaN(parsed)) return parsed;
121
+ } catch {
122
+ // Try the other authoritative run document.
123
+ }
124
+ }
125
+ return null;
126
+ }
@@ -16,6 +16,7 @@ export interface ResourceProcessSample {
16
16
  owner_kind: "agent" | "service" | "unattributed";
17
17
  owner_id: string | null;
18
18
  owner_root_pid: number | null;
19
+ owner_source?: "pid-map" | "session-environment" | "service";
19
20
  }
20
21
 
21
22
  export interface ResourceProcessGroup {
@@ -90,6 +91,12 @@ export interface ResourceSamplerState {
90
91
  cpu_total_ticks: number;
91
92
  cpu_idle_ticks: number;
92
93
  process_ticks: Map<string, number>;
94
+ process_owners: Map<string, ResourceProcessOwnerProof>;
95
+ }
96
+
97
+ export interface ResourceProcessOwnerProof {
98
+ session_id: string;
99
+ instance_id: string;
93
100
  }
94
101
 
95
102
  export interface ResourceSampleResult {
@@ -3,11 +3,13 @@ import { existsSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
3
3
  import { cpus, loadavg, platform, release } from "node:os";
4
4
  import { basename, join } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
+ import { readLiveCoordinationRows } from "../agents/state/live-coordination-view.ts";
6
7
  import { parsePidmapRow } from "../agents/state/pidmap.ts";
7
8
  import { checkPidToken } from "../agents/state/proc-start.ts";
8
9
  import {
9
10
  RESOURCE_SNAPSHOT_SCHEMA_VERSION,
10
11
  type ResourceProcessGroup,
12
+ type ResourceProcessOwnerProof,
11
13
  type ResourceProcessSample,
12
14
  type ResourceSampleResult,
13
15
  type ResourceSamplerState,
@@ -44,11 +46,19 @@ interface ResourceSamplerOptions {
44
46
  clockTicks?: number;
45
47
  pageSize?: number;
46
48
  services?: readonly { pid: number; id: string }[];
49
+ sessionOwners?: readonly SessionOwner[];
47
50
  unattributedCpuFloor?: number;
48
51
  unattributedRssFloor?: number;
49
52
  maxProcesses?: number;
50
53
  }
51
54
 
55
+ interface SessionOwner {
56
+ nativeSessionId: string;
57
+ instanceId: string;
58
+ platform: string;
59
+ kind: string;
60
+ }
61
+
52
62
  let cachedClockTicks: number | undefined;
53
63
  let cachedPageSize: number | undefined;
54
64
 
@@ -86,7 +96,24 @@ export function sampleResources(
86
96
  const cpu = parseLinuxCpu(readFileSync(join(procRoot, "stat"), "utf8"));
87
97
  const memory = parseMeminfo(readFileSync(join(procRoot, "meminfo"), "utf8"));
88
98
  const readings = readLinuxProcesses(procRoot, clockTicks, pageSize, uptimeSeconds);
89
- const anchors = readOwnershipAnchors(coordRoot, readings, options.services ?? []);
99
+ const liveSessionOwners = uniqueLiveCodexSessionOwners(
100
+ options.sessionOwners ??
101
+ readLiveCoordinationRows(coordRoot).map((row) => ({
102
+ nativeSessionId: row.native_session_id ?? "",
103
+ instanceId: row.instance_id,
104
+ platform: row.platform ?? "",
105
+ kind: row.kind ?? "",
106
+ })),
107
+ );
108
+ const ownership = readOwnershipAnchors(
109
+ coordRoot,
110
+ procRoot,
111
+ readings,
112
+ options.services ?? [],
113
+ liveSessionOwners,
114
+ previous?.process_owners,
115
+ );
116
+ const anchors = ownership.anchors;
90
117
  const byPid = new Map(readings.map((reading) => [reading.pid, reading]));
91
118
  const processTicks = new Map<string, number>();
92
119
  const intervalMs = previous ? Math.max(0, nowMs - previous.sampled_at_ms) : null;
@@ -116,6 +143,7 @@ export function sampleResources(
116
143
  owner_kind: ownership?.kind ?? "unattributed",
117
144
  owner_id: ownership?.id ?? null,
118
145
  owner_root_pid: ownership?.rootPid ?? null,
146
+ ...(ownership ? { owner_source: ownership.source } : {}),
119
147
  };
120
148
  });
121
149
  const visible = processes
@@ -182,6 +210,7 @@ export function sampleResources(
182
210
  cpu_total_ticks: cpu.total,
183
211
  cpu_idle_ticks: cpu.idle,
184
212
  process_ticks: processTicks,
213
+ process_owners: ownership.processOwners,
185
214
  },
186
215
  };
187
216
  }
@@ -288,13 +317,20 @@ function readLinuxProcesses(
288
317
  interface OwnershipAnchor {
289
318
  kind: "agent" | "service";
290
319
  id: string;
320
+ source: "pid-map" | "session-environment" | "service";
291
321
  }
292
322
 
293
323
  function readOwnershipAnchors(
294
324
  coordRoot: string,
325
+ procRoot: string,
295
326
  readings: readonly LinuxProcessReading[],
296
327
  services: readonly { pid: number; id: string }[],
297
- ): Map<number, OwnershipAnchor> {
328
+ liveSessionOwners: ReadonlyMap<string, string>,
329
+ previousOwners?: ReadonlyMap<string, ResourceProcessOwnerProof>,
330
+ ): {
331
+ anchors: Map<number, OwnershipAnchor>;
332
+ processOwners: Map<string, ResourceProcessOwnerProof>;
333
+ } {
298
334
  const byPid = new Set(readings.map((row) => row.pid));
299
335
  const directory = join(coordRoot, ".harnery", "pid-map");
300
336
  const anchors = new Map<number, OwnershipAnchor>();
@@ -306,25 +342,121 @@ function readOwnershipAnchors(
306
342
  try {
307
343
  const row = parsePidmapRow(readFileSync(join(directory, entry), "utf8"));
308
344
  if (!row.instanceId || checkPidToken(pid, row.startToken) === "mismatch") continue;
309
- anchors.set(pid, { kind: "agent", id: row.instanceId });
345
+ anchors.set(pid, { kind: "agent", id: row.instanceId, source: "pid-map" });
310
346
  } catch {
311
347
  // An unreadable or racing row cannot prove ownership.
312
348
  }
313
349
  }
314
350
  }
351
+
352
+ const processOwners = readBridgeProcessOwners(
353
+ procRoot,
354
+ readings,
355
+ liveSessionOwners,
356
+ previousOwners,
357
+ );
358
+ const byReadingPid = new Map(readings.map((row) => [row.pid, row]));
359
+ for (const reading of readings) {
360
+ const identity = `${reading.pid}:${reading.startTicks}`;
361
+ const owner = processOwners.get(identity);
362
+ if (!owner || anchors.has(reading.pid)) continue;
363
+ const parent = byReadingPid.get(reading.ppid);
364
+ const parentOwner = parent
365
+ ? processOwners.get(`${parent.pid}:${parent.startTicks}`)
366
+ : undefined;
367
+ if (parentOwner?.instance_id === owner.instance_id) continue;
368
+ anchors.set(reading.pid, {
369
+ kind: "agent",
370
+ id: owner.instance_id,
371
+ source: "session-environment",
372
+ });
373
+ }
374
+
315
375
  for (const service of services) {
316
376
  if (byPid.has(service.pid) && service.id.trim()) {
317
- anchors.set(service.pid, { kind: "service", id: service.id.trim() });
377
+ anchors.set(service.pid, { kind: "service", id: service.id.trim(), source: "service" });
318
378
  }
319
379
  }
320
- return anchors;
380
+ return { anchors, processOwners };
381
+ }
382
+
383
+ function uniqueLiveCodexSessionOwners(rows: readonly SessionOwner[]): Map<string, string> {
384
+ const candidates = new Map<string, Set<string>>();
385
+ for (const row of rows) {
386
+ const sessionId = row.nativeSessionId.trim();
387
+ const instanceId = row.instanceId.trim();
388
+ if (row.platform !== "codex" || row.kind !== "session" || !sessionId || !instanceId) continue;
389
+ const owners = candidates.get(sessionId) ?? new Set<string>();
390
+ owners.add(instanceId);
391
+ candidates.set(sessionId, owners);
392
+ }
393
+ const result = new Map<string, string>();
394
+ for (const [sessionId, owners] of candidates) {
395
+ if (owners.size === 1) result.set(sessionId, [...owners][0]!);
396
+ }
397
+ return result;
398
+ }
399
+
400
+ function readBridgeProcessOwners(
401
+ procRoot: string,
402
+ readings: readonly LinuxProcessReading[],
403
+ liveSessionOwners: ReadonlyMap<string, string>,
404
+ previousOwners?: ReadonlyMap<string, ResourceProcessOwnerProof>,
405
+ ): Map<string, ResourceProcessOwnerProof> {
406
+ const result = new Map<string, ResourceProcessOwnerProof>();
407
+ if (liveSessionOwners.size === 0) return result;
408
+ for (const reading of readings) {
409
+ const identity = `${reading.pid}:${reading.startTicks}`;
410
+ const previous = previousOwners?.get(identity);
411
+ if (previous && liveSessionOwners.get(previous.session_id) === previous.instance_id) {
412
+ result.set(identity, previous);
413
+ continue;
414
+ }
415
+ const sessionId = readCodexWslBridgeSessionId(procRoot, reading.pid);
416
+ if (!sessionId) continue;
417
+ const instanceId = liveSessionOwners.get(sessionId);
418
+ if (instanceId) result.set(identity, { session_id: sessionId, instance_id: instanceId });
419
+ }
420
+ return result;
421
+ }
422
+
423
+ function readCodexWslBridgeSessionId(procRoot: string, pid: number): string | undefined {
424
+ try {
425
+ const entries = readFileSync(join(procRoot, String(pid), "environ"), "utf8").split("\0");
426
+ let bridge: string | undefined;
427
+ let platform: string | undefined;
428
+ let coordinationSessionId: string | undefined;
429
+ let codexThreadId: string | undefined;
430
+ for (const entry of entries) {
431
+ if (entry.startsWith("HARNERY_AGENT_COORD_BRIDGE=")) {
432
+ bridge = entry.slice("HARNERY_AGENT_COORD_BRIDGE=".length);
433
+ } else if (entry.startsWith("HARNERY_AGENT_COORD_PLATFORM=")) {
434
+ platform = entry.slice("HARNERY_AGENT_COORD_PLATFORM=".length);
435
+ } else if (entry.startsWith("HARNERY_AGENT_COORD_SESSION_ID=")) {
436
+ coordinationSessionId = entry.slice("HARNERY_AGENT_COORD_SESSION_ID=".length);
437
+ } else if (entry.startsWith("CODEX_THREAD_ID=")) {
438
+ codexThreadId = entry.slice("CODEX_THREAD_ID=".length);
439
+ }
440
+ }
441
+ if (
442
+ bridge === "codex-wsl" &&
443
+ platform === "codex" &&
444
+ coordinationSessionId &&
445
+ coordinationSessionId === codexThreadId
446
+ ) {
447
+ return coordinationSessionId;
448
+ }
449
+ } catch {
450
+ // An unreadable or racing environment cannot prove ownership.
451
+ }
452
+ return undefined;
321
453
  }
322
454
 
323
455
  function resolveOwnership(
324
456
  pid: number,
325
457
  byPid: ReadonlyMap<number, LinuxProcessReading>,
326
458
  anchors: ReadonlyMap<number, OwnershipAnchor>,
327
- ): { kind: "agent" | "service"; id: string; rootPid: number } | undefined {
459
+ ): (OwnershipAnchor & { rootPid: number }) | undefined {
328
460
  const seen = new Set<number>();
329
461
  let cursor = pid;
330
462
  for (let hops = 0; hops < 64 && cursor > 0 && !seen.has(cursor); hops++) {