@wrongstack/wrongtrace 0.313.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/client.js ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * The integrated WrongTrace client.
3
+ *
4
+ * ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐
5
+ * │ HTTP / REST │ ←→ │ WrongTrace │ ←→ │ IPC / MCP opt. │
6
+ * │ (always) │ │ Client │ │ (if discovered) │
7
+ * └────────────────┘ └────────────────┘ └──────────────────┘
8
+ *
9
+ * Strategy:
10
+ * - IPC-first: when `/api/health` reports a `socket_path`, JSON-RPC 2.0
11
+ * over the pipe is preferred for every method the daemon exposes there.
12
+ * Daemon v0.3.3 (live-verified 2026-08-24) answers telemetry/file_health,
13
+ * telemetry/report_run, guardrail/unlock and get_atlas on the pipe;
14
+ * older daemons reply -32601, which the transport surfaces as
15
+ * {result:null} → HTTP fallback, so routing is a no-op there.
16
+ * - ONE exception: guardrail/lock answers on the pipe but does NOT enforce
17
+ * conflicts — a live probe (2026-08-24) showed IPC lock with force:false
18
+ * silently TAKES OVER another owner's lock instead of rejecting with the
19
+ * -32009 envelope the integration letter promises. lockFile therefore
20
+ * stays HTTP-first to preserve the 409 conflict semantics production
21
+ * gates depend on. Flip it when the daemon enforces conflicts on IPC.
22
+ * - HTTP is the universal substrate — every method has an HTTP path and
23
+ * serves as the fallback when the pipe is absent or fails.
24
+ * - MCP is used when the host runtime supplies an MCP tool bag. In that
25
+ * mode we prefer IPC, then the named MCP tools (lock_file,
26
+ * get_file_health_score, etc.), then HTTP.
27
+ *
28
+ * Every method returns `null` (or `[]` for list endpoints) when the
29
+ * underlying transport failed OR the daemon was never reachable. The
30
+ * protocol promises callers can wire this client unconditionally and
31
+ * "pass" when WrongTrace is offline.
32
+ */
33
+ import { createMcpTransport } from "./adapters/mcp.js";
34
+ import { createIpcTransport } from "./adapters/ipc.js";
35
+ import { discover } from "./discovery.js";
36
+ const DEFAULT_TIMEOUT_MS = 4_000;
37
+ class HttpError extends Error {
38
+ status;
39
+ constructor(status, message) {
40
+ super(message);
41
+ this.status = status;
42
+ this.name = "HttpError";
43
+ }
44
+ }
45
+ async function httpJson(baseUrl, path, init) {
46
+ const fetchImpl = globalThis.fetch;
47
+ if (typeof fetchImpl !== "function")
48
+ return null;
49
+ const controller = new AbortController();
50
+ const timer = setTimeout(() => controller.abort(), init?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
51
+ try {
52
+ const headers = { Accept: "application/json" };
53
+ if (init?.body !== undefined)
54
+ headers["Content-Type"] = "application/json";
55
+ const reqInit = {
56
+ method: init?.method ?? "GET",
57
+ signal: controller.signal,
58
+ headers,
59
+ };
60
+ if (init?.body !== undefined)
61
+ reqInit.body = JSON.stringify(init.body);
62
+ const res = await fetchImpl(`${baseUrl}${path}`, reqInit);
63
+ // Non-2xx statuses listed in acceptStatus carry a structured body the
64
+ // caller wants (e.g. lock conflicts: 409 + {ok:false, owner, expires_at}).
65
+ // Surface those bodies instead of swallowing them into null.
66
+ if (!res.ok && !(init?.acceptStatus ?? []).includes(res.status)) {
67
+ throw new HttpError(res.status, `${init?.method ?? "GET"} ${path} → ${res.status}`);
68
+ }
69
+ return (await res.json());
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ export async function createWrongTraceClient(opts = {}) {
79
+ const discovery = await discover(opts);
80
+ const mcp = createMcpTransport(opts.mcpTools);
81
+ const ipc = createIpcTransport(discovery.socketPath);
82
+ const requireBaseUrl = () => {
83
+ if (!discovery.available || !discovery.baseUrl)
84
+ return null;
85
+ return discovery.baseUrl;
86
+ };
87
+ const notAvailable = () => null;
88
+ const client = {
89
+ _discovery: discovery,
90
+ get isAvailable() {
91
+ return discovery.available;
92
+ },
93
+ get baseUrl() {
94
+ return discovery.baseUrl;
95
+ },
96
+ get socketPath() {
97
+ return discovery.socketPath;
98
+ },
99
+ async getHealth() {
100
+ const base = requireBaseUrl();
101
+ if (!base)
102
+ return notAvailable();
103
+ return httpJson(base, "/api/health");
104
+ },
105
+ async getFileHealth(path) {
106
+ const base = requireBaseUrl();
107
+ if (!base)
108
+ return notAvailable();
109
+ // IPC-first: live-verified JSON-RPC method on the daemon's pipe
110
+ // (both instances, 2026-08-24). Falls back to HTTP when the pipe is
111
+ // absent, unreachable, or answers with an error envelope.
112
+ if (ipc.isWired) {
113
+ const viaIpc = await ipc.call("telemetry/file_health", { file_path: path });
114
+ if (viaIpc.result)
115
+ return viaIpc.result;
116
+ }
117
+ if (mcp.isWired) {
118
+ const viaMcp = await mcp.invoke("get_file_health_score", { path });
119
+ if (viaMcp)
120
+ return viaMcp;
121
+ }
122
+ return httpJson(base, `/api/file/health?path=${encodeURIComponent(path)}`);
123
+ },
124
+ async getSymbolLineage(path, signature) {
125
+ const base = requireBaseUrl();
126
+ if (!base)
127
+ return [];
128
+ // Daemon round-3: signature is optional — path-only returns every
129
+ // symbol event for the file. Loose names like "foo()" still yield [],
130
+ // so callers wanting a specific symbol should pass a daemon-format
131
+ // signature (`function:file.go::Name`); otherwise omit it.
132
+ const params = new URLSearchParams();
133
+ params.set("path", path);
134
+ if (signature !== undefined && signature !== "")
135
+ params.set("signature", signature);
136
+ const data = await httpJson(base, `/api/symbol/history?${params.toString()}`);
137
+ return Array.isArray(data) ? data : [];
138
+ },
139
+ async getFrictionMatrix(limit = 50) {
140
+ const base = requireBaseUrl();
141
+ if (!base)
142
+ return [];
143
+ const data = await httpJson(base, `/api/metrics/friction?limit=${limit}`);
144
+ // The daemon returns a report object ({edges, recent_collisions,
145
+ // total_collisions, ...}), not a bare array. Normalize: bare array
146
+ // passes through; report shape yields its edges, carrying
147
+ // `recent_collisions` as metadata so single-call consumers
148
+ // (getRecentActivity) don't lose the per-file collision history.
149
+ if (Array.isArray(data))
150
+ return data;
151
+ if (data && typeof data === "object") {
152
+ const report = data;
153
+ if (Array.isArray(report.edges)) {
154
+ const rows = report.edges;
155
+ if (Array.isArray(report.recent_collisions))
156
+ rows.recent_collisions = report.recent_collisions;
157
+ return rows;
158
+ }
159
+ }
160
+ return [];
161
+ },
162
+ async getAtlas(query) {
163
+ const base = requireBaseUrl();
164
+ if (!base)
165
+ return notAvailable();
166
+ const params = new URLSearchParams();
167
+ if (query?.workspace !== undefined && query.workspace !== "")
168
+ params.set("workspace", query.workspace);
169
+ if (query?.summary === true)
170
+ params.set("summary", "true");
171
+ if (query?.includeSymbols === false)
172
+ params.set("include_symbols", "false");
173
+ if (query?.limit !== undefined)
174
+ params.set("limit", String(query.limit));
175
+ if (query?.offset !== undefined)
176
+ params.set("offset", String(query.offset));
177
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
178
+ // IPC-first since daemon v0.3.3 (live-verified 2026-08-24): get_atlas
179
+ // answers JSON-RPC on the pipe. Older daemons reply -32601 →
180
+ // {result:null} → HTTP fallback below. summary=true keeps the pipe
181
+ // payload small; full atlases are multi-hundred-ms either transport.
182
+ if (ipc.isWired) {
183
+ const viaIpc = await ipc.call("get_atlas", {
184
+ ...(query?.workspace !== undefined && query.workspace !== "" ? { workspace: query.workspace } : {}),
185
+ ...(query?.summary === true ? { summary: true } : {}),
186
+ ...(query?.includeSymbols === false ? { include_symbols: false } : {}),
187
+ ...(query?.limit !== undefined ? { limit: query.limit } : {}),
188
+ ...(query?.offset !== undefined ? { offset: query.offset } : {}),
189
+ });
190
+ if (viaIpc.result)
191
+ return viaIpc.result;
192
+ }
193
+ return httpJson(base, `/api/atlas${qs}`);
194
+ },
195
+ async lockFile(path, reason, opts) {
196
+ // Deliberately HTTP-first — the ONE IPC exception; see the strategy
197
+ // header. Live probe 2026-08-24: IPC guardrail/lock ignores conflicts
198
+ // (silently takes over even with force:false), so routing lock calls
199
+ // through the pipe would break the 409-conflict semantics the
200
+ // production guardrail depends on.
201
+ const body = { path, reason };
202
+ if (opts?.owner !== undefined)
203
+ body.owner = opts.owner;
204
+ if (opts?.ownerRunId !== undefined)
205
+ body.owner_run_id = opts.ownerRunId;
206
+ if (opts?.ttlSeconds !== undefined)
207
+ body.ttl_seconds = opts.ttlSeconds;
208
+ if (opts?.force === true)
209
+ body.force = true;
210
+ if (mcp.isWired) {
211
+ const viaMcp = await mcp.invoke("lock_file", { ...body });
212
+ if (viaMcp)
213
+ return viaMcp;
214
+ }
215
+ const base = requireBaseUrl();
216
+ if (!base)
217
+ return notAvailable();
218
+ // 409 = lock conflict: the daemon returns {ok:false, owner, owner_run_id,
219
+ // locked_at, expires_at, error, message} — exactly what the caller needs
220
+ // to decide whether to wait or take over, so pass the body through.
221
+ return httpJson(base, "/api/guardrail/lock", {
222
+ method: "POST",
223
+ body,
224
+ acceptStatus: [409],
225
+ });
226
+ },
227
+ async unlockFile(path) {
228
+ // IPC-first since daemon v0.3.3 (live-verified 2026-08-24): the pipe
229
+ // shares the daemon's lock store — an IPC unlock releases HTTP-acquired
230
+ // locks. Shape differs from HTTP: {file_path, status} with no `ok`,
231
+ // normalized here to the HTTP contract.
232
+ if (ipc.isWired) {
233
+ const viaIpc = await ipc.call("guardrail/unlock", { path });
234
+ if (viaIpc.result) {
235
+ return {
236
+ ok: true,
237
+ path: viaIpc.result.path ?? viaIpc.result.file_path ?? path,
238
+ status: viaIpc.result.status ?? "unlocked",
239
+ };
240
+ }
241
+ }
242
+ const body = { path };
243
+ if (mcp.isWired) {
244
+ const viaMcp = await mcp.invoke("unlock_file", { ...body });
245
+ if (viaMcp)
246
+ return viaMcp;
247
+ }
248
+ const base = requireBaseUrl();
249
+ if (!base)
250
+ return notAvailable();
251
+ return httpJson(base, "/api/guardrail/unlock", { method: "POST", body });
252
+ },
253
+ async reportTelemetry(report) {
254
+ // IPC-first: telemetry/report_run is a live JSON-RPC method on the
255
+ // daemon's pipe (both instances, 2026-08-24). HTTP fallback keeps the
256
+ // no-op contract when the pipe is absent or fails.
257
+ if (ipc.isWired) {
258
+ const viaIpc = await ipc.call("telemetry/report_run", {
259
+ ...report,
260
+ });
261
+ if (viaIpc.result) {
262
+ // Daemon answers {"status":"ok"} — normalize to the HTTP contract.
263
+ const ok = viaIpc.result.ok ?? viaIpc.result.status === "ok";
264
+ return { ok };
265
+ }
266
+ }
267
+ if (mcp.isWired) {
268
+ const viaMcp = await mcp.invoke("report_telemetry", { ...report });
269
+ if (viaMcp)
270
+ return viaMcp;
271
+ }
272
+ const base = requireBaseUrl();
273
+ if (!base)
274
+ return notAvailable();
275
+ return httpJson(base, "/api/telemetry", { method: "POST", body: report });
276
+ },
277
+ async getRecentEvents(query) {
278
+ const base = requireBaseUrl();
279
+ if (!base)
280
+ return [];
281
+ const params = new URLSearchParams();
282
+ if (query?.limit !== undefined)
283
+ params.set("limit", String(query.limit));
284
+ if (query?.since !== undefined)
285
+ params.set("since", query.since);
286
+ if (query?.repo !== undefined)
287
+ params.set("repo", query.repo);
288
+ if (query?.filePath !== undefined)
289
+ params.set("file_path", query.filePath);
290
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
291
+ const data = await httpJson(base, `/api/events/recent${qs}`);
292
+ return Array.isArray(data) ? data : [];
293
+ },
294
+ async listLocks() {
295
+ const base = requireBaseUrl();
296
+ if (!base)
297
+ return [];
298
+ const data = await httpJson(base, "/api/guardrail/locks");
299
+ return Array.isArray(data) ? data : [];
300
+ },
301
+ };
302
+ return client;
303
+ }
304
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Discovers whether the external WrongTrace AI Observability daemon is
3
+ * reachable, and if so, where to talk to it.
4
+ *
5
+ * Discovery protocol (per the integration spec):
6
+ * 1. HTTP probe of `${baseUrl}/api/health` with a tight timeout.
7
+ * 2. If the daemon replies 2xx, read `socket_path` from the body for the
8
+ * IPC path. If absent, fall back to platform-default locations.
9
+ * 3. If MCP tools are registered globally (e.g. via `wrongtrace mcp`),
10
+ * MCP transport is preferred over HTTP/IPC for actual calls.
11
+ *
12
+ * The whole routine never throws — it returns a `DiscoveryResult` whose
13
+ * `available` flag tells the caller to either bind the full client or
14
+ * install a typed no-op shim. That makes it safe to invoke from the hot
15
+ * path of boot.
16
+ */
17
+ export interface DiscoveryOptions {
18
+ /** Override the base URL. Default: process.env.WRONGTRACE_URL ?? "http://localhost:3444". */
19
+ baseUrl?: string;
20
+ /** Probe timeout in ms. Default: 1000. */
21
+ timeoutMs?: number;
22
+ /**
23
+ * Inject a `fetch`-compatible implementation. Tests use this to stub the
24
+ * probe without monkey-patching globals.
25
+ */
26
+ fetchImpl?: typeof fetch;
27
+ }
28
+ export interface DiscoveryResult {
29
+ available: boolean;
30
+ baseUrl?: string;
31
+ socketPath?: string;
32
+ version?: string;
33
+ }
34
+ /** Default IPC paths per platform — only consulted when `/api/health` did not return `socket_path`. */
35
+ export declare function defaultSocketPath(home?: string): string;
36
+ export declare function discover(opts?: DiscoveryOptions): Promise<DiscoveryResult>;
37
+ //# sourceMappingURL=discovery.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Discovers whether the external WrongTrace AI Observability daemon is
3
+ * reachable, and if so, where to talk to it.
4
+ *
5
+ * Discovery protocol (per the integration spec):
6
+ * 1. HTTP probe of `${baseUrl}/api/health` with a tight timeout.
7
+ * 2. If the daemon replies 2xx, read `socket_path` from the body for the
8
+ * IPC path. If absent, fall back to platform-default locations.
9
+ * 3. If MCP tools are registered globally (e.g. via `wrongtrace mcp`),
10
+ * MCP transport is preferred over HTTP/IPC for actual calls.
11
+ *
12
+ * The whole routine never throws — it returns a `DiscoveryResult` whose
13
+ * `available` flag tells the caller to either bind the full client or
14
+ * install a typed no-op shim. That makes it safe to invoke from the hot
15
+ * path of boot.
16
+ */
17
+ import { platform } from "node:os";
18
+ import { join } from "node:path";
19
+ /** Default IPC paths per platform — only consulted when `/api/health` did not return `socket_path`. */
20
+ export function defaultSocketPath(home = process.env.HOME ?? process.env.USERPROFILE ?? "") {
21
+ if (platform() === "win32")
22
+ return "\\\\.\\pipe\\wrongtrace.sock";
23
+ if (home)
24
+ return join(home, ".wrongtrace", "ipc.sock");
25
+ return "/tmp/wrongtrace.sock";
26
+ }
27
+ export async function discover(opts = {}) {
28
+ const baseUrl = opts.baseUrl ?? process.env.WRONGTRACE_URL ?? "http://localhost:3444";
29
+ const timeoutMs = opts.timeoutMs ?? 1000;
30
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
31
+ if (typeof fetchImpl !== "function") {
32
+ return { available: false };
33
+ }
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
36
+ try {
37
+ const res = await fetchImpl(`${baseUrl}/api/health`, {
38
+ signal: controller.signal,
39
+ headers: { Accept: "application/json" },
40
+ });
41
+ if (!res.ok)
42
+ return { available: false, baseUrl };
43
+ const body = (await res.json().catch(() => ({})));
44
+ // Accept either contract the daemon might speak:
45
+ // * { ok: true } (older / strict boolean schema)
46
+ // * { status: "ok" } (current WrongProxy-style schema)
47
+ if (body?.ok !== true && body?.status !== "ok")
48
+ return { available: false, baseUrl };
49
+ const result = { available: true, baseUrl };
50
+ if (typeof body.socket_path === "string")
51
+ result.socketPath = body.socket_path;
52
+ else
53
+ result.socketPath = defaultSocketPath();
54
+ if (typeof body.version === "string")
55
+ result.version = body.version;
56
+ return result;
57
+ }
58
+ catch {
59
+ return { available: false, baseUrl };
60
+ }
61
+ finally {
62
+ clearTimeout(timer);
63
+ }
64
+ }
65
+ //# sourceMappingURL=discovery.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * WrongTrace gate-decision counter — hosted in the shared adapter so EVERY
3
+ * process that runs the gate (CLI leader + fleet, standalone WebUI server)
4
+ * records into the same tally contract and the same counters file.
5
+ *
6
+ * Design:
7
+ * - Pure, transport-agnostic tally: `record()` accepts the typed event;
8
+ * `snapshot()` returns one number per decision kind.
9
+ * - Deliberately NOT wired as a new EventBus listener — the host emit
10
+ * sites call `recordGateDecision()` inside their existing emit
11
+ * closures, so this module never registers a listener it must remember
12
+ * to dispose (the EventBus wildcard/named caps punish undisposed
13
+ * registrations; see the core EventBus leak board card).
14
+ * - A snapshot is persisted to `<projectRoot>/.wrongstack/
15
+ * wrongtrace-gate-counters.json` so the standalone `wstack
16
+ * proxy-status` command (fresh process) can report the latest writer's
17
+ * CUMULATIVE firing rates — cross-process measurability.
18
+ *
19
+ * Shared file contract (CLI + standalone WebUI): both processes write the
20
+ * same path with the same snapshot shape; each process tallies its own
21
+ * sessions (module singleton per process), and the last writer wins. The
22
+ * CLI persists at session end (finalizeExecutionCleanup); the standalone
23
+ * WebUI server persists on each gate decision (its host session model has
24
+ * no single session-end hook). `wstack proxy-status` reads whatever the
25
+ * latest writer produced.
26
+ */
27
+ import type { WrongTraceGateDecisionEvent } from './hooks.js';
28
+ export interface WrongTraceGateCounterSnapshot {
29
+ deny: number;
30
+ allowFragile: number;
31
+ lockAcquired: number;
32
+ lockConflictRace: number;
33
+ lockReleased: number;
34
+ total: number;
35
+ }
36
+ export interface WrongTraceGateCounter {
37
+ record(event: WrongTraceGateDecisionEvent): void;
38
+ snapshot(): WrongTraceGateCounterSnapshot;
39
+ reset(): void;
40
+ }
41
+ export declare function createWrongTraceGateCounter(): WrongTraceGateCounter;
42
+ /** Record one gate decision into the process-shared tally. */
43
+ export declare function recordGateDecision(event: WrongTraceGateDecisionEvent): void;
44
+ /** Snapshot the process-shared tally (for session-end persist / doctor). */
45
+ export declare function snapshotGateDecisions(): WrongTraceGateCounterSnapshot;
46
+ export declare function resetGateDecisions(): void;
47
+ export declare function countersFilePath(projectRoot: string): string;
48
+ /** Best-effort persist — the doctor surface must never fail a session end. */
49
+ export declare function persistWrongTraceGateCounters(projectRoot: string, snapshot: WrongTraceGateCounterSnapshot): Promise<void>;
50
+ export declare function loadWrongTraceGateCounters(projectRoot: string): Promise<WrongTraceGateCounterSnapshot | null>;
51
+ /** Compact one-line report for the doctor surface. */
52
+ export declare function formatGateCounterReport(s: WrongTraceGateCounterSnapshot): string;
53
+ //# sourceMappingURL=gate-counters.d.ts.map
@@ -0,0 +1,161 @@
1
+ /**
2
+ * WrongTrace gate-decision counter — hosted in the shared adapter so EVERY
3
+ * process that runs the gate (CLI leader + fleet, standalone WebUI server)
4
+ * records into the same tally contract and the same counters file.
5
+ *
6
+ * Design:
7
+ * - Pure, transport-agnostic tally: `record()` accepts the typed event;
8
+ * `snapshot()` returns one number per decision kind.
9
+ * - Deliberately NOT wired as a new EventBus listener — the host emit
10
+ * sites call `recordGateDecision()` inside their existing emit
11
+ * closures, so this module never registers a listener it must remember
12
+ * to dispose (the EventBus wildcard/named caps punish undisposed
13
+ * registrations; see the core EventBus leak board card).
14
+ * - A snapshot is persisted to `<projectRoot>/.wrongstack/
15
+ * wrongtrace-gate-counters.json` so the standalone `wstack
16
+ * proxy-status` command (fresh process) can report the latest writer's
17
+ * CUMULATIVE firing rates — cross-process measurability.
18
+ *
19
+ * Shared file contract (CLI + standalone WebUI): both processes write the
20
+ * same path with the same snapshot shape; each process tallies its own
21
+ * sessions (module singleton per process), and the last writer wins. The
22
+ * CLI persists at session end (finalizeExecutionCleanup); the standalone
23
+ * WebUI server persists on each gate decision (its host session model has
24
+ * no single session-end hook). `wstack proxy-status` reads whatever the
25
+ * latest writer produced.
26
+ */
27
+ import { promises as fs } from 'node:fs';
28
+ import * as path from 'node:path';
29
+ export function createWrongTraceGateCounter() {
30
+ let deny = 0;
31
+ let allowFragile = 0;
32
+ let lockAcquired = 0;
33
+ let lockConflictRace = 0;
34
+ let lockReleased = 0;
35
+ return {
36
+ record(event) {
37
+ switch (event.kind) {
38
+ case 'deny':
39
+ deny++;
40
+ break;
41
+ case 'allow-fragile':
42
+ allowFragile++;
43
+ break;
44
+ case 'lock-acquired':
45
+ lockAcquired++;
46
+ break;
47
+ case 'lock-conflict-race':
48
+ lockConflictRace++;
49
+ break;
50
+ case 'lock-released':
51
+ lockReleased++;
52
+ break;
53
+ }
54
+ },
55
+ snapshot() {
56
+ return {
57
+ deny,
58
+ allowFragile,
59
+ lockAcquired,
60
+ lockConflictRace,
61
+ lockReleased,
62
+ total: deny + allowFragile + lockAcquired + lockConflictRace + lockReleased,
63
+ };
64
+ },
65
+ reset() {
66
+ deny = 0;
67
+ allowFragile = 0;
68
+ lockAcquired = 0;
69
+ lockConflictRace = 0;
70
+ lockReleased = 0;
71
+ },
72
+ };
73
+ }
74
+ const COUNTERS_FILE = path.join('.wrongstack', 'wrongtrace-gate-counters.json');
75
+ // Process-shared singleton — wired at the host emit sites (CLI leader +
76
+ // fleet, standalone WebUI server) and read at session end / on each gate
77
+ // decision, without registering any new EventBus listener (the leak
78
+ // discipline from the EventBus board card).
79
+ const shared = createWrongTraceGateCounter();
80
+ /** Record one gate decision into the process-shared tally. */
81
+ export function recordGateDecision(event) {
82
+ shared.record(event);
83
+ }
84
+ /** Snapshot the process-shared tally (for session-end persist / doctor). */
85
+ export function snapshotGateDecisions() {
86
+ return shared.snapshot();
87
+ }
88
+ export function resetGateDecisions() {
89
+ shared.reset();
90
+ }
91
+ export function countersFilePath(projectRoot) {
92
+ return path.join(projectRoot, COUNTERS_FILE);
93
+ }
94
+ // In-process serialization so unawaited per-decision persists (the
95
+ // standalone WebUI emit closure) cannot interleave: each write awaits the
96
+ // previous one, then publishes atomically via temp-file + rename (an
97
+ // interrupted write never leaves a truncated counters file).
98
+ let persistChain = Promise.resolve();
99
+ /** Best-effort persist — the doctor surface must never fail a session end. */
100
+ export function persistWrongTraceGateCounters(projectRoot, snapshot) {
101
+ // Chain so overlapping callers serialize; fail-open and never reject the
102
+ // returned promise (observability must not break teardown).
103
+ persistChain = persistChain.then(async () => {
104
+ const file = countersFilePath(projectRoot);
105
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
106
+ try {
107
+ await fs.mkdir(path.dirname(file), { recursive: true });
108
+ await fs.writeFile(tmp, JSON.stringify({ at: new Date().toISOString(), ...snapshot }, null, 2), 'utf8');
109
+ await fs.rename(tmp, file);
110
+ }
111
+ catch {
112
+ // best-effort: leave the prior file intact (rename is atomic; a failed
113
+ // write before it only orphans the tmp file).
114
+ try {
115
+ await fs.unlink(tmp).catch(() => { });
116
+ }
117
+ catch {
118
+ /* ignore */
119
+ }
120
+ }
121
+ });
122
+ return persistChain;
123
+ }
124
+ export async function loadWrongTraceGateCounters(projectRoot) {
125
+ try {
126
+ const raw = await fs.readFile(countersFilePath(projectRoot), 'utf8');
127
+ const parsed = JSON.parse(raw);
128
+ if (typeof parsed.deny !== 'number' ||
129
+ typeof parsed.allowFragile !== 'number' ||
130
+ typeof parsed.lockAcquired !== 'number' ||
131
+ typeof parsed.lockConflictRace !== 'number' ||
132
+ typeof parsed.lockReleased !== 'number') {
133
+ return null;
134
+ }
135
+ // Strip the `at` timestamp persist adds — the loader returns the pure
136
+ // counter shape so callers (doctor readout, tests) get exactly the
137
+ // snapshot contract, not the storage envelope.
138
+ return {
139
+ deny: parsed.deny,
140
+ allowFragile: parsed.allowFragile,
141
+ lockAcquired: parsed.lockAcquired,
142
+ lockConflictRace: parsed.lockConflictRace,
143
+ lockReleased: parsed.lockReleased,
144
+ total: parsed.deny +
145
+ parsed.allowFragile +
146
+ parsed.lockAcquired +
147
+ parsed.lockConflictRace +
148
+ parsed.lockReleased,
149
+ };
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ /** Compact one-line report for the doctor surface. */
156
+ export function formatGateCounterReport(s) {
157
+ return (`deny=${s.deny} allow-fragile=${s.allowFragile} ` +
158
+ `lock-acquired=${s.lockAcquired} lock-conflict-race=${s.lockConflictRace} ` +
159
+ `lock-released=${s.lockReleased} total=${s.total}`);
160
+ }
161
+ //# sourceMappingURL=gate-counters.js.map