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
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Per-session page-QA signal: the pointer that lets `agents status` report a
3
+ * verdict with the runner's own clock instead of leaving an operator to read
4
+ * session age as if it were QA time.
5
+ *
6
+ * The problem this closes: a status box showing `session 58m` invites the
7
+ * reading "page QA took 58 minutes". Session age measures the agent, not the
8
+ * run. So the runner's wall time is recorded here and rendered beside the
9
+ * verdict, and admission-queue wait is carried as a separate number because
10
+ * `wall_time_ms.total` is runner stages only and never includes the queue
11
+ * (see QaRunResult.wall_time_ms in the toolkit contracts).
12
+ *
13
+ * Layering: the qa-run matrix runner is toolkit tier (`src/lib/browser/**`)
14
+ * and must not import `src/core`, so the runner cannot write this pointer
15
+ * itself. The command layer owns the write and calls `recordQaSignal()` after
16
+ * a run finishes. Core importing toolkit *types* is the permitted direction.
17
+ *
18
+ * Storage: `<coordRoot>/.harnery/qa/<instance-id>.json`, one file per session
19
+ * generation, atomic temp+rename so a concurrent status read never sees a
20
+ * torn write. Last run wins; each run directory remains the authoritative
21
+ * record of the run itself.
22
+ *
23
+ * Every function here is best-effort by contract. A missing, unreadable,
24
+ * malformed, or partial pointer yields null rather than throwing: the status
25
+ * box must render even when QA state is broken.
26
+ */
27
+
28
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
29
+ import { resolve } from "node:path";
30
+ import type {
31
+ QaRunEvidenceSource,
32
+ QaRunResult,
33
+ QaRunVerdict,
34
+ } from "../../lib/browser/qa-run-contracts.ts";
35
+ import { resolveCoordRoot, resolveOwner } from "./coord-client.ts";
36
+
37
+ export const QA_SIGNAL_SCHEMA_VERSION = 1 as const;
38
+
39
+ /** Pointers older than this render as `stale (<age>)` and nothing else: an
40
+ * age-of-day-old verdict says nothing about the page as it stands now, and
41
+ * showing its timings beside a current session invites the same conflation
42
+ * this signal exists to prevent. */
43
+ export const QA_SIGNAL_STALE_AFTER_MS = 24 * 60 * 60 * 1000;
44
+
45
+ /** Timings split the way the result contract splits them: `total` is runner
46
+ * stages only, `queue` is admission wait before any browser work and is never
47
+ * part of `total`. */
48
+ export interface QaSignalWallTime {
49
+ total: number;
50
+ queue?: number;
51
+ }
52
+
53
+ export interface QaSignalPointer {
54
+ schema_version: typeof QA_SIGNAL_SCHEMA_VERSION;
55
+ run_id: string;
56
+ verdict: QaRunVerdict;
57
+ evidence_source: QaRunEvidenceSource;
58
+ /** ISO-8601 UTC instant the run completed. */
59
+ completed_at: string;
60
+ /** Absolute run directory holding the authoritative result document. */
61
+ out_dir: string;
62
+ wall_time_ms: QaSignalWallTime;
63
+ target: string;
64
+ }
65
+
66
+ const INSTANCE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
67
+ const VERDICTS: readonly QaRunVerdict[] = ["passed", "failed", "incomplete"];
68
+ const EVIDENCE_SOURCES: readonly QaRunEvidenceSource[] = ["runner", "manual"];
69
+
70
+ /**
71
+ * Absolute path of one session's QA pointer. Throws on an instance id that
72
+ * could escape the directory; callers in this module treat that as "no
73
+ * pointer" rather than propagating it.
74
+ */
75
+ export function qaSignalPath(coordRoot: string, instanceId: string): string {
76
+ if (!INSTANCE_ID_PATTERN.test(instanceId)) {
77
+ throw new Error("instance_id must be 1-128 ASCII letters, digits, hyphens, or underscores");
78
+ }
79
+ const directory = resolve(coordRoot, ".harnery", "qa");
80
+ const candidate = resolve(directory, `${instanceId}.json`);
81
+ if (!candidate.startsWith(`${directory}/`)) {
82
+ throw new Error("resolved qa signal path escapes the qa directory");
83
+ }
84
+ return candidate;
85
+ }
86
+
87
+ export interface QaSignalTarget {
88
+ /** Defaults to the resolved coordination root. */
89
+ coordRoot?: string | null;
90
+ /** Defaults to the current session's instance id. */
91
+ instanceId?: string | null;
92
+ }
93
+
94
+ function resolveTarget(target: QaSignalTarget): { coordRoot: string; instanceId: string } | null {
95
+ const coordRoot = target.coordRoot ?? resolveCoordRoot();
96
+ const instanceId = target.instanceId ?? resolveOwner();
97
+ if (!coordRoot || !instanceId) return null;
98
+ return { coordRoot, instanceId };
99
+ }
100
+
101
+ /**
102
+ * Write the pointer for one completed run. Returns what was written, or null
103
+ * when the session/root could not be resolved or the write failed — a QA run
104
+ * must never fail because its status breadcrumb could not be recorded.
105
+ */
106
+ export function recordQaSignal(
107
+ result: QaRunResult,
108
+ target: QaSignalTarget = {},
109
+ ): QaSignalPointer | null {
110
+ try {
111
+ const resolved = resolveTarget(target);
112
+ if (!resolved) return null;
113
+ const pointer: QaSignalPointer = {
114
+ schema_version: QA_SIGNAL_SCHEMA_VERSION,
115
+ run_id: result.run.run_id,
116
+ verdict: result.verdict,
117
+ evidence_source: result.evidence_source,
118
+ completed_at: result.run.completed_at,
119
+ out_dir: result.run.out_dir,
120
+ wall_time_ms: {
121
+ total: result.wall_time_ms.total,
122
+ ...(typeof result.wall_time_ms.queue === "number"
123
+ ? { queue: result.wall_time_ms.queue }
124
+ : {}),
125
+ },
126
+ target: result.target,
127
+ };
128
+ const path = qaSignalPath(resolved.coordRoot, resolved.instanceId);
129
+ mkdirSync(resolve(path, ".."), { recursive: true });
130
+ const tmp = `${path}.tmp.${process.pid}`;
131
+ writeFileSync(tmp, `${JSON.stringify(pointer, null, 2)}\n`, "utf8");
132
+ renameSync(tmp, path);
133
+ return pointer;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** Read one session's pointer. Null when absent, unreadable, or malformed. */
140
+ export function readQaSignal(target: QaSignalTarget = {}): QaSignalPointer | null {
141
+ try {
142
+ const resolved = resolveTarget(target);
143
+ if (!resolved) return null;
144
+ const path = qaSignalPath(resolved.coordRoot, resolved.instanceId);
145
+ if (!existsSync(path)) return null;
146
+ return parseQaSignal(JSON.parse(readFileSync(path, "utf8")));
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Validate an untrusted pointer document. Fail-closed: a partial document —
154
+ * the shape a torn write or a schema change produces — is not a pointer, and
155
+ * renders no row at all rather than a half-true one.
156
+ */
157
+ export function parseQaSignal(document: unknown): QaSignalPointer | null {
158
+ if (!document || typeof document !== "object" || Array.isArray(document)) return null;
159
+ const value = document as Record<string, unknown>;
160
+ if (value.schema_version !== QA_SIGNAL_SCHEMA_VERSION) return null;
161
+ if (typeof value.run_id !== "string" || value.run_id.length === 0) return null;
162
+ if (!VERDICTS.includes(value.verdict as QaRunVerdict)) return null;
163
+ if (!EVIDENCE_SOURCES.includes(value.evidence_source as QaRunEvidenceSource)) return null;
164
+ if (typeof value.completed_at !== "string" || Number.isNaN(Date.parse(value.completed_at))) {
165
+ return null;
166
+ }
167
+ if (typeof value.out_dir !== "string" || value.out_dir.length === 0) return null;
168
+ if (typeof value.target !== "string" || value.target.length === 0) return null;
169
+ const wall = value.wall_time_ms;
170
+ if (!wall || typeof wall !== "object" || Array.isArray(wall)) return null;
171
+ const { total, queue } = wall as Record<string, unknown>;
172
+ if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return null;
173
+ if (queue !== undefined && (typeof queue !== "number" || !Number.isFinite(queue) || queue < 0)) {
174
+ return null;
175
+ }
176
+ return {
177
+ schema_version: QA_SIGNAL_SCHEMA_VERSION,
178
+ run_id: value.run_id,
179
+ verdict: value.verdict as QaRunVerdict,
180
+ evidence_source: value.evidence_source as QaRunEvidenceSource,
181
+ completed_at: value.completed_at,
182
+ out_dir: value.out_dir,
183
+ wall_time_ms: { total, ...(typeof queue === "number" ? { queue } : {}) },
184
+ target: value.target,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Coarse single-unit age, matching the status box's existing age vocabulary
190
+ * (`4m`, `3h`, `2d`). Deliberately coarser than the duration formatter: an
191
+ * operator reads age to judge relevance, not to compare timings.
192
+ */
193
+ export function formatQaSignalAge(ms: number): string {
194
+ const seconds = Math.max(0, Math.floor(ms / 1000));
195
+ if (seconds < 60) return `${seconds}s`;
196
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
197
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
198
+ return `${Math.floor(seconds / 86400)}d`;
199
+ }
200
+
201
+ /**
202
+ * Duration in the units an operator compares runs by. Seconds stay seconds up
203
+ * to two minutes so a 90-second run reads `90s` rather than being rounded into
204
+ * a minute bucket that hides the difference between fast runs.
205
+ */
206
+ export function formatQaSignalDuration(ms: number): string {
207
+ const value = Math.max(0, ms);
208
+ if (value < 120_000) return `${Math.round(value / 1000)}s`;
209
+ if (value < 3_600_000) return `${Math.round(value / 60_000)}m`;
210
+ const hours = Math.floor(value / 3_600_000);
211
+ const minutes = Math.round((value % 3_600_000) / 60_000);
212
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
213
+ }
214
+
215
+ /**
216
+ * Render the status-box `qa` value, or null when there is nothing honest to
217
+ * say. Three shapes:
218
+ * - fresh runner evidence: `passed 4m ago · 90s runner (2m queued)`
219
+ * - fresh manual evidence: `manual 12m ago · not a pass`
220
+ * - anything over the staleness horizon: `stale (2d)`
221
+ *
222
+ * Manual evidence never reports a verdict or a runner clock: nothing
223
+ * re-executable ran, so the contract caps it below a pass and there is no
224
+ * runner time to attribute.
225
+ */
226
+ export function formatQaSignalRow(
227
+ pointer: QaSignalPointer | null,
228
+ now: number = Date.now(),
229
+ ): string | null {
230
+ if (!pointer) return null;
231
+ const completedAt = Date.parse(pointer.completed_at);
232
+ if (Number.isNaN(completedAt)) return null;
233
+ const ageMs = Math.max(0, now - completedAt);
234
+ const age = formatQaSignalAge(ageMs);
235
+ if (ageMs > QA_SIGNAL_STALE_AFTER_MS) return `stale (${age})`;
236
+ if (pointer.evidence_source === "manual") return `manual ${age} ago · not a pass`;
237
+ const runner = `${formatQaSignalDuration(pointer.wall_time_ms.total)} runner`;
238
+ const queued =
239
+ typeof pointer.wall_time_ms.queue === "number" && pointer.wall_time_ms.queue > 0
240
+ ? ` (${formatQaSignalDuration(pointer.wall_time_ms.queue)} queued)`
241
+ : "";
242
+ return `${pointer.verdict} ${age} ago · ${runner}${queued}`;
243
+ }
244
+
245
+ /**
246
+ * The whole status-box contribution in one best-effort call: read this
247
+ * session's pointer and render its row. Null means render no `qa` row.
248
+ */
249
+ export function qaSignalStatusRow(
250
+ target: QaSignalTarget = {},
251
+ now: number = Date.now(),
252
+ ): { value: string; pointer: QaSignalPointer } | null {
253
+ try {
254
+ const pointer = readQaSignal(target);
255
+ const value = formatQaSignalRow(pointer, now);
256
+ if (!pointer || !value) return null;
257
+ return { value, pointer };
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
@@ -8,6 +8,7 @@
8
8
  export interface SessionNameDisplayState {
9
9
  suggested_session_name?: string;
10
10
  session_name_seen_for?: string;
11
+ session_name_display_requested_for?: string;
11
12
  }
12
13
 
13
14
  export const SESSION_NAME_DISPLAY_NOTE =
@@ -21,6 +22,45 @@ export function sessionNameDisplayPending(
21
22
  return name;
22
23
  }
23
24
 
25
+ /**
26
+ * Every title whose exact display satisfies the current latch.
27
+ *
28
+ * The pending title always counts. The title the agent was last instructed to
29
+ * display also counts, because the harness — not the agent — is what changed
30
+ * the target after asking. Accepting it costs nothing the operator can see: a
31
+ * clean one-line block did open that reply. Refusing it left Cursor sessions
32
+ * permanently latched, since no Cursor surface can re-open a missed window.
33
+ */
34
+ export function sessionNameDisplayAcceptedNames(
35
+ row: SessionNameDisplayState | null | undefined,
36
+ ): string[] {
37
+ const pending = sessionNameDisplayPending(row);
38
+ if (!pending) return [];
39
+ const requested = row?.session_name_display_requested_for;
40
+ return requested && requested !== pending ? [pending, requested] : [pending];
41
+ }
42
+
43
+ /**
44
+ * Match assistant text against any accepted title and report which one closed
45
+ * the latch, so a drifted display can be stamped against the pending title and
46
+ * logged as drift rather than silently discarded.
47
+ */
48
+ export function matchSessionNameDisplay(
49
+ row: SessionNameDisplayState | null | undefined,
50
+ text: string | undefined,
51
+ startsWithBlock: (
52
+ text: string,
53
+ expectedName: string,
54
+ ) => boolean = assistantTextStartsWithSessionNameBlock,
55
+ ): { pending: string; displayed: string } | null {
56
+ if (!text) return null;
57
+ const accepted = sessionNameDisplayAcceptedNames(row);
58
+ const pending = accepted[0];
59
+ if (!pending) return null;
60
+ const displayed = accepted.find((candidate) => startsWithBlock(text, candidate));
61
+ return displayed ? { pending, displayed } : null;
62
+ }
63
+
24
64
  export function sessionNameDisplayBlock(name: string): string {
25
65
  return `\`\`\`\n${name}\n\`\`\``;
26
66
  }
@@ -80,10 +120,16 @@ function responseContainsSessionNameMint(
80
120
  return true;
81
121
  }
82
122
  } catch {
83
- // Wrapper prose and partial JSON are not authoritative mint evidence.
123
+ // Fall through to the textual check below.
84
124
  }
85
125
  }
86
- return false;
126
+ // The mint command's stdout often reaches the transcript mangled: cut
127
+ // short by `cut`/`head -c`, or with a tail-truncated first line. Whole-JSON
128
+ // parsing then fails even though the name and the mint flag are both
129
+ // legible, and a session that did mint can never latch. Accept the text
130
+ // when the exact quoted name sits under its key AND a mint flag is true;
131
+ // ordinary status output carries the name without any flag.
132
+ return textCarriesSessionNameMint(value, name);
87
133
  }
88
134
 
89
135
  if (typeof value !== "object") return false;
@@ -107,16 +153,41 @@ function responseContainsSessionNameMint(
107
153
  );
108
154
  }
109
155
 
156
+ const MINT_FLAG_PATTERN = /"(?:first_of_session|name_reminted|session_name_retry)"\s*:\s*true\b/;
157
+
158
+ function textCarriesSessionNameMint(text: string, name: string): boolean {
159
+ const quotedName = JSON.stringify(name);
160
+ const keyIndex = text.indexOf('"suggested_session_name"');
161
+ if (keyIndex < 0) return false;
162
+ const afterKey = text.slice(keyIndex + '"suggested_session_name"'.length);
163
+ const valueMatch = /^\s*:\s*("(?:[^"\\]|\\.)*")/.exec(afterKey);
164
+ if (!valueMatch || valueMatch[1] !== quotedName) return false;
165
+ return MINT_FLAG_PATTERN.test(text);
166
+ }
167
+
110
168
  /**
111
- * Accept the exact unlabelled block Harnery requests. `text` and `plaintext`
112
- * are tolerated for older prompts, but the code block may contain only the
113
- * suggested name and must be the first non-whitespace user-facing content.
169
+ * A leading fenced block whose only line is the suggested name.
170
+ *
171
+ * What the contract protects is what the operator sees: the reply opens with a
172
+ * code block containing exactly the title, so it renders as one clean
173
+ * copy-pasteable line. The fence's info string changes none of that, so any
174
+ * single-word language tag passes. Restricting it to `text`/`plaintext` meant a
175
+ * model that wrote ```txt or ```markdown produced a display the operator could
176
+ * read perfectly while the latch refused to close — and since nothing else can
177
+ * close a Cursor latch, that mismatch stranded the whole session.
178
+ *
179
+ * Still exact in every load-bearing way: the block is the first user-facing
180
+ * content, holds one line, and that line equals the name. The backreference
181
+ * requires the closing fence to match the opening run, so a longer fence cannot
182
+ * be closed by a shorter one.
114
183
  */
184
+ const LEADING_SESSION_NAME_BLOCK =
185
+ /^(`{3,})[ \t]*[A-Za-z0-9_.+#-]*[ \t]*\n([^\n]*)\n\1[ \t]*(?:\n|$)/;
186
+
115
187
  export function assistantTextStartsWithSessionNameBlock(text: string, name: string): boolean {
116
188
  if (!text || !name) return false;
117
189
  const normalized = text.replace(/\r\n?/g, "\n").trimStart();
118
- const match = /^```(?:text|plaintext)?[ \t]*\n([^\n]*)\n```(?:[ \t]*(?:\n|$))/.exec(normalized);
119
- return match?.[1] === name;
190
+ return LEADING_SESSION_NAME_BLOCK.exec(normalized)?.[2] === name;
120
191
  }
121
192
 
122
193
  /**
@@ -33,6 +33,13 @@ export interface Heartbeat {
33
33
  session_name_seen_at?: string;
34
34
  /** The specific suggested name proven present by the sighting stamp. */
35
35
  session_name_seen_for?: string;
36
+ /**
37
+ * The suggested name the agent was last actually instructed to display.
38
+ * Usually identical to `suggested_session_name`; it differs only when the
39
+ * title changed after the instruction went out, and a display of it still
40
+ * counts so that drift cannot strand the latch.
41
+ */
42
+ session_name_display_requested_for?: string;
36
43
  last_status_at?: string;
37
44
  current_turn_id?: string;
38
45
  parent_instance_id?: string;
@@ -180,6 +180,29 @@ export function stampSessionNameSeen(
180
180
  }));
181
181
  }
182
182
 
183
+ /**
184
+ * Remember which title the agent was actually asked to display.
185
+ *
186
+ * The gate can only ask for a display through the PostToolUse instruction, and
187
+ * the title it names can change afterwards (an assigned-name rewrite of an
188
+ * `Agent unknown - ...` suggestion, a lifecycle re-mint, a rebuilt cache that
189
+ * re-minted from the current task). Without this record the agent's correct
190
+ * display of the title it was handed matches nothing, the latch never closes,
191
+ * and on Cursor no later surface can repair it.
192
+ */
193
+ export function stampSessionNameRequested(
194
+ coordRoot: string,
195
+ instanceId: string,
196
+ name: string,
197
+ ): Heartbeat | null {
198
+ if (!name) return null;
199
+ return mutate(coordRoot, instanceId, (hb) =>
200
+ hb.session_name_display_requested_for === name
201
+ ? hb
202
+ : { ...hb, session_name_display_requested_for: name },
203
+ );
204
+ }
205
+
183
206
  export function releaseClaim(
184
207
  coordRoot: string,
185
208
  instanceId: string,
@@ -197,6 +197,7 @@ export function projectHeartbeatV3(
197
197
  suggested_session_name: cache?.suggested_session_name,
198
198
  session_name_seen_at: cache?.session_name_seen_at,
199
199
  session_name_seen_for: cache?.session_name_seen_for,
200
+ session_name_display_requested_for: cache?.session_name_display_requested_for,
200
201
  workflow_run_id: generation.run_id,
201
202
  workflow_agent_id: generation.workflow_agent_id,
202
203
  parent_instance_id: parent ? nativeInstanceIdV3(parent.instance_id) : undefined,
@@ -107,6 +107,11 @@ function materializeLiveCoordinationHeartbeat(
107
107
  ...(current.session_name_seen_at
108
108
  ? { session_name_seen_at: current.session_name_seen_at }
109
109
  : {}),
110
+ ...(current.session_name_display_requested_for
111
+ ? {
112
+ session_name_display_requested_for: current.session_name_display_requested_for,
113
+ }
114
+ : {}),
110
115
  }
111
116
  : {};
112
117
  const materialized: V3HeartbeatMaterialization = {
@@ -1,4 +1,4 @@
1
1
  /** Read-only artifact layout constants safe for browser-side server modules. */
2
2
  export const ARTIFACTS_DIR = ".harnery/artifacts";
3
3
  export const ARTIFACT_MANIFEST = ".harnery-artifact.json";
4
- export const ARTIFACT_SCHEMA_VERSION = 1 as const;
4
+ export const ARTIFACT_SCHEMA_VERSION = 2 as const;