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.
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +7 -2
- package/dist/commands/admission.d.ts +21 -0
- package/dist/commands/admission.d.ts.map +1 -0
- package/dist/commands/admission.js +565 -0
- package/dist/commands/agents.d.ts +7 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +61 -1
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +48 -2
- package/dist/commands/browse-ai.d.ts +2 -2
- package/dist/commands/browse-ai.d.ts.map +1 -1
- package/dist/commands/browse-ai.js +6 -4
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +349 -21
- package/dist/commands/fetch.js +1 -0
- package/dist/commands/qa-record.d.ts +144 -0
- package/dist/commands/qa-record.d.ts.map +1 -0
- package/dist/commands/qa-record.js +0 -0
- package/dist/commands/qa-run.d.ts +7 -3
- package/dist/commands/qa-run.d.ts.map +1 -1
- package/dist/commands/qa-run.js +268 -13
- package/dist/commands/qa-status.d.ts +71 -0
- package/dist/commands/qa-status.d.ts.map +1 -0
- package/dist/commands/qa-status.js +490 -0
- package/dist/commands/qa-verify.d.ts +40 -0
- package/dist/commands/qa-verify.d.ts.map +1 -0
- package/dist/commands/qa-verify.js +180 -0
- package/dist/commands/review-pack.d.ts +4 -0
- package/dist/commands/review-pack.d.ts.map +1 -0
- package/dist/commands/review-pack.js +1001 -0
- package/dist/core/agents/qa-signal.d.ts +111 -0
- package/dist/core/agents/qa-signal.d.ts.map +1 -0
- package/dist/core/agents/qa-signal.js +231 -0
- package/dist/core/agents/session-name-display.d.ts +20 -5
- package/dist/core/agents/session-name-display.d.ts.map +1 -1
- package/dist/core/agents/session-name-display.js +67 -7
- package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
- package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +17 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +1 -0
- package/dist/core/agents/state/live-coordination-writer.js +5 -0
- package/dist/core/artifacts/constants.d.ts +1 -1
- package/dist/core/artifacts/constants.js +1 -1
- package/dist/core/artifacts/index.d.ts +57 -6
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +265 -10
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +16 -0
- package/dist/core/diagnostics/bundle.d.ts +16 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +101 -7
- package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
- package/dist/core/events/v3/bootstrap.js +10 -0
- package/dist/core/events/v3/coordination-view.d.ts +3 -0
- package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
- package/dist/core/events/v3/coordination-view.js +69 -8
- package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
- package/dist/core/events/v3/producers/intake.js +9 -2
- package/dist/core/events/v3/producers/recorder.d.ts +23 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +264 -18
- package/dist/core/hooks/cli.js +120 -27
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +10 -3
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
- package/dist/core/hooks/session-name-presence.js +4 -1
- package/dist/core/qa-artifacts.d.ts +20 -0
- package/dist/core/qa-artifacts.d.ts.map +1 -0
- package/dist/core/qa-artifacts.js +110 -0
- package/dist/core/resources/contract.d.ts +6 -0
- package/dist/core/resources/contract.d.ts.map +1 -1
- package/dist/core/resources/sampler.d.ts +7 -0
- package/dist/core/resources/sampler.d.ts.map +1 -1
- package/dist/core/resources/sampler.js +106 -5
- package/dist/lib/admission.d.ts +71 -0
- package/dist/lib/admission.d.ts.map +1 -0
- package/dist/lib/admission.js +264 -0
- package/dist/lib/agent-browser/client.d.ts +1 -1
- package/dist/lib/agent-browser/client.d.ts.map +1 -1
- package/dist/lib/agent-browser/client.js +1 -5
- package/dist/lib/browser/capture-fidelity.d.ts +39 -0
- package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
- package/dist/lib/browser/capture-fidelity.js +84 -0
- package/dist/lib/browser/client.d.ts +41 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +167 -9
- package/dist/lib/browser/critique.d.ts +38 -1
- package/dist/lib/browser/critique.d.ts.map +1 -1
- package/dist/lib/browser/critique.js +34 -6
- package/dist/lib/browser/index.d.ts +4 -2
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +2 -0
- package/dist/lib/browser/page-review-judge.d.ts +64 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
- package/dist/lib/browser/page-review-judge.js +270 -0
- package/dist/lib/browser/page-review-pack.d.ts +613 -0
- package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
- package/dist/lib/browser/page-review-pack.js +1751 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run-contracts.js +136 -1
- package/dist/lib/browser/qa-run.d.ts +100 -10
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +768 -169
- package/dist/lib/browser/request-diagnostics.d.ts +13 -0
- package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
- package/dist/lib/browser/request-diagnostics.js +18 -0
- package/dist/lib/browser/tiling.d.ts +19 -0
- package/dist/lib/browser/tiling.d.ts.map +1 -1
- package/dist/lib/browser/tiling.js +28 -0
- package/dist/lib/cookies/client.d.ts +9 -0
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +197 -44
- package/dist/lib/cookies/extra.d.ts +18 -0
- package/dist/lib/cookies/extra.d.ts.map +1 -0
- package/dist/lib/cookies/extra.js +14 -0
- package/dist/lib/cookies/index.d.ts +2 -1
- package/dist/lib/cookies/index.d.ts.map +1 -1
- package/dist/lib/cookies/index.js +2 -1
- package/dist/lib/durable-job.d.ts +124 -0
- package/dist/lib/durable-job.d.ts.map +1 -0
- package/dist/lib/durable-job.js +296 -0
- package/dist/lib/http/client.d.ts +7 -1
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +2 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +5 -2
- package/package.json +8 -2
- package/src/commander.ts +50 -2
- package/src/commands/admission.ts +699 -0
- package/src/commands/agents.ts +87 -1
- package/src/commands/artifacts.ts +97 -21
- package/src/commands/browse-ai.ts +10 -5
- package/src/commands/browse.ts +481 -20
- package/src/commands/fetch.ts +1 -0
- package/src/commands/qa-record.ts +682 -0
- package/src/commands/qa-run.ts +335 -16
- package/src/commands/qa-status.ts +608 -0
- package/src/commands/qa-verify.ts +238 -0
- package/src/commands/review-pack.ts +1281 -0
- package/src/core/agents/qa-signal.ts +261 -0
- package/src/core/agents/session-name-display.ts +78 -7
- package/src/core/agents/state/heartbeat-reader.ts +7 -0
- package/src/core/agents/state/heartbeat-writer.ts +23 -0
- package/src/core/agents/state/live-coordination-view.ts +1 -0
- package/src/core/agents/state/live-coordination-writer.ts +5 -0
- package/src/core/artifacts/constants.ts +1 -1
- package/src/core/artifacts/index.ts +370 -21
- package/src/core/config.ts +23 -0
- package/src/core/diagnostics/bundle.ts +119 -11
- package/src/core/events/v3/bootstrap.ts +10 -0
- package/src/core/events/v3/coordination-view.ts +100 -11
- package/src/core/events/v3/producers/intake.ts +9 -2
- package/src/core/events/v3/producers/recorder.ts +312 -18
- package/src/core/hooks/cli.ts +140 -32
- package/src/core/hooks/resolve/transcript.ts +10 -3
- package/src/core/hooks/session-name-presence.ts +6 -1
- package/src/core/qa-artifacts.ts +126 -0
- package/src/core/resources/contract.ts +7 -0
- package/src/core/resources/sampler.ts +138 -6
- package/src/lib/admission.ts +347 -0
- package/src/lib/agent-browser/client.ts +2 -10
- package/src/lib/browser/capture-fidelity.ts +98 -0
- package/src/lib/browser/client.ts +206 -10
- package/src/lib/browser/critique.ts +62 -7
- package/src/lib/browser/index.ts +36 -0
- package/src/lib/browser/page-review-judge.ts +360 -0
- package/src/lib/browser/page-review-pack.ts +2384 -0
- package/src/lib/browser/qa-run-contracts.ts +366 -3
- package/src/lib/browser/qa-run.ts +868 -190
- package/src/lib/browser/request-diagnostics.ts +27 -0
- package/src/lib/browser/tiling.ts +32 -0
- package/src/lib/cookies/client.ts +228 -42
- package/src/lib/cookies/extra.ts +28 -0
- package/src/lib/cookies/index.ts +2 -0
- package/src/lib/durable-job.ts +407 -0
- package/src/lib/http/client.ts +13 -1
- 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
|
-
//
|
|
123
|
+
// Fall through to the textual check below.
|
|
84
124
|
}
|
|
85
125
|
}
|
|
86
|
-
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
-
|
|
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 =
|
|
4
|
+
export const ARTIFACT_SCHEMA_VERSION = 2 as const;
|