@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ECOSTACK TECHNOLOGY OÜ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * IPC transport adapter for WrongTrace — JSON-RPC 2.0 over Named Pipe / UDS.
3
+ *
4
+ * Wire format (daemon 2026-08-24+):
5
+ * request: {"jsonrpc":"2.0","id":N,"method":"telemetry/file_health","params":{...}}\n
6
+ * response: {"jsonrpc":"2.0","id":N,"result":{...}}\n
7
+ * |{"jsonrpc":"2.0","id":N,"error":{"code":-32601,"message":"..."}}\n
8
+ *
9
+ * Live-verified (2026-08-24, both \\.\pipe\wrongtrace and \\.\pipe\wrongtrace-int):
10
+ * only `telemetry/file_health` and `telemetry/report_run` answer on the pipe;
11
+ * guardrail/atlas exist solely as HTTP routes. The legacy REST-over-pipe
12
+ * framing ({"method":"GET","path":...}) is GONE from the daemon — it now
13
+ * replies -32601 "method not found: GET", so this adapter no longer sends it.
14
+ *
15
+ * Degradation contract (mirrors the HTTP client): `call()` NEVER throws.
16
+ * Transport failures (connect refused, timeout, malformed frames) resolve
17
+ * `{ result: null }`; daemon error envelopes resolve `{ result: null, error }`.
18
+ * Callers fall back to HTTP instead of mistaking an envelope for a result —
19
+ * the exact bug the legacy framing produced in `getAtlas()`.
20
+ */
21
+ export interface IpcTimeouts {
22
+ connectTimeoutMs?: number;
23
+ readTimeoutMs?: number;
24
+ }
25
+ export interface IpcCallResult<T = unknown> {
26
+ /** JSON-RPC `result` body when the call succeeded, else `null`. */
27
+ result: T | null;
28
+ /** Present only when the daemon replied with a JSON-RPC error envelope. */
29
+ error?: {
30
+ code: number;
31
+ message: string;
32
+ };
33
+ }
34
+ export interface IpcTransport {
35
+ /** Always `false` when constructed without a socketPath. */
36
+ readonly isWired: boolean;
37
+ /**
38
+ * Round-trips one JSON-RPC 2.0 call over a fresh connection. One request
39
+ * per connection means any error envelope on the wire is unambiguously
40
+ * ours — no cross-connection id confusion at these latencies (~0.3ms).
41
+ */
42
+ call<T = unknown>(method: string, params: Record<string, unknown>): Promise<IpcCallResult<T>>;
43
+ }
44
+ declare class TimeoutError extends Error {
45
+ constructor(ms: number);
46
+ }
47
+ export declare function createIpcTransport(socketPath?: string, timeouts?: IpcTimeouts): IpcTransport;
48
+ export { TimeoutError as IpcTimeoutError };
49
+ //# sourceMappingURL=ipc.d.ts.map
@@ -0,0 +1,127 @@
1
+ /**
2
+ * IPC transport adapter for WrongTrace — JSON-RPC 2.0 over Named Pipe / UDS.
3
+ *
4
+ * Wire format (daemon 2026-08-24+):
5
+ * request: {"jsonrpc":"2.0","id":N,"method":"telemetry/file_health","params":{...}}\n
6
+ * response: {"jsonrpc":"2.0","id":N,"result":{...}}\n
7
+ * |{"jsonrpc":"2.0","id":N,"error":{"code":-32601,"message":"..."}}\n
8
+ *
9
+ * Live-verified (2026-08-24, both \\.\pipe\wrongtrace and \\.\pipe\wrongtrace-int):
10
+ * only `telemetry/file_health` and `telemetry/report_run` answer on the pipe;
11
+ * guardrail/atlas exist solely as HTTP routes. The legacy REST-over-pipe
12
+ * framing ({"method":"GET","path":...}) is GONE from the daemon — it now
13
+ * replies -32601 "method not found: GET", so this adapter no longer sends it.
14
+ *
15
+ * Degradation contract (mirrors the HTTP client): `call()` NEVER throws.
16
+ * Transport failures (connect refused, timeout, malformed frames) resolve
17
+ * `{ result: null }`; daemon error envelopes resolve `{ result: null, error }`.
18
+ * Callers fall back to HTTP instead of mistaking an envelope for a result —
19
+ * the exact bug the legacy framing produced in `getAtlas()`.
20
+ */
21
+ import { connect } from "node:net";
22
+ const CONNECT_TIMEOUT_MS = 2_000;
23
+ const READ_TIMEOUT_MS = 5_000;
24
+ class TimeoutError extends Error {
25
+ constructor(ms) {
26
+ super(`IPC request exceeded ${ms}ms`);
27
+ this.name = "TimeoutError";
28
+ }
29
+ }
30
+ function once(emitter, event, predicate = () => true) {
31
+ return new Promise((resolve, reject) => {
32
+ const onAny = (...args) => {
33
+ if (predicate(...args)) {
34
+ emitter.removeListener("error", onError);
35
+ emitter.removeListener(event, onAny);
36
+ resolve(args);
37
+ }
38
+ };
39
+ const onError = (err) => {
40
+ emitter.removeListener(event, onAny);
41
+ reject(err);
42
+ };
43
+ emitter.once(event, onAny);
44
+ emitter.once("error", onError);
45
+ });
46
+ }
47
+ // JSON-RPC ids only need to be unique per connection, but a process-wide
48
+ // counter costs nothing and makes correlation observable in logs/probes.
49
+ let nextRequestId = 1;
50
+ export function createIpcTransport(socketPath, timeouts) {
51
+ if (!socketPath) {
52
+ return {
53
+ isWired: false,
54
+ async call() {
55
+ return { result: null };
56
+ },
57
+ };
58
+ }
59
+ const connectTimeoutMs = timeouts?.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
60
+ const readTimeoutMs = timeouts?.readTimeoutMs ?? READ_TIMEOUT_MS;
61
+ return {
62
+ isWired: true,
63
+ async call(method, params) {
64
+ const sock = connect(socketPath);
65
+ const id = nextRequestId++;
66
+ let timer;
67
+ const cleanup = () => {
68
+ if (timer)
69
+ clearTimeout(timer);
70
+ sock.destroy();
71
+ };
72
+ try {
73
+ await Promise.race([
74
+ once(sock, "connect"),
75
+ new Promise((_, reject) => {
76
+ timer = setTimeout(() => reject(new TimeoutError(connectTimeoutMs)), connectTimeoutMs);
77
+ }),
78
+ ]);
79
+ clearTimeout(timer);
80
+ timer = undefined;
81
+ const payload = `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
82
+ sock.write(payload);
83
+ timer = setTimeout(() => sock.destroy(new TimeoutError(readTimeoutMs)), readTimeoutMs);
84
+ // Line-buffered frame reader: accumulate until the newline-delimited
85
+ // frame carrying our response arrives. Junk/partial lines are skipped,
86
+ // not fatal — the daemon is allowed to chatter before answering.
87
+ let buffer = "";
88
+ for await (const chunk of sock) {
89
+ buffer += chunk.toString("utf8");
90
+ let newlineAt = buffer.indexOf("\n");
91
+ while (newlineAt !== -1) {
92
+ const line = buffer.slice(0, newlineAt).trim();
93
+ buffer = buffer.slice(newlineAt + 1);
94
+ newlineAt = buffer.indexOf("\n");
95
+ if (line.length === 0)
96
+ continue;
97
+ let envelope;
98
+ try {
99
+ envelope = JSON.parse(line);
100
+ }
101
+ catch {
102
+ continue; // malformed line — tolerate, keep scanning
103
+ }
104
+ if (envelope.error) {
105
+ return { result: null, error: envelope.error };
106
+ }
107
+ if (envelope.id === id || envelope.id === null) {
108
+ return { result: (envelope.result ?? null) };
109
+ }
110
+ // Different id — a frame for someone else; keep scanning.
111
+ }
112
+ }
113
+ // Socket closed without a usable frame.
114
+ return { result: null };
115
+ }
116
+ catch {
117
+ return { result: null };
118
+ }
119
+ finally {
120
+ cleanup();
121
+ }
122
+ },
123
+ };
124
+ }
125
+ // Re-exporting for tests / advanced consumers that want the underlying timeout error.
126
+ export { TimeoutError as IpcTimeoutError };
127
+ //# sourceMappingURL=ipc.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * MCP transport adapter for WrongTrace.
3
+ *
4
+ * When the daemon is exposed as an MCP server (e.g. via `wrongtrace mcp`),
5
+ * the integration protocol can call its tools directly instead of HTTP.
6
+ * The adapter hides the lookup so `WrongTraceClient` can stay oblivious.
7
+ *
8
+ * MCP discovery is lazy: the adapter does NOT require the MCP SDK at
9
+ * construction time. We accept a `tools` bag from the caller — typically
10
+ * populated from `mcp_control.list()` in the host runtime. If no tools
11
+ * are provided, every call resolves with `null`, same as HTTP/IPC
12
+ * failure paths.
13
+ */
14
+ import type { WrongTraceHealth, WrongTraceLockResult } from "../types.js";
15
+ export type McpToolName = "check_guardrail" | "get_file_health_score" | "get_symbol_lineage" | "get_friction_matrix" | "get_atlas" | "lock_file" | "unlock_file" | "report_telemetry";
16
+ export type McpToolHandler = (args: Record<string, unknown>) => Promise<unknown>;
17
+ export type McpToolBag = Partial<Record<McpToolName, McpToolHandler>>;
18
+ export interface McpTransport {
19
+ readonly isWired: boolean;
20
+ readonly availableTools: McpToolName[];
21
+ invoke<T = unknown>(tool: McpToolName, args: Record<string, unknown>): Promise<T | null>;
22
+ }
23
+ export declare function createMcpTransport(tools?: McpToolBag): McpTransport;
24
+ export declare const mcp: {
25
+ health(_health: WrongTraceHealth | null): McpToolName | null;
26
+ lockResult(result: WrongTraceLockResult | null): WrongTraceLockResult | null;
27
+ };
28
+ //# sourceMappingURL=mcp.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * MCP transport adapter for WrongTrace.
3
+ *
4
+ * When the daemon is exposed as an MCP server (e.g. via `wrongtrace mcp`),
5
+ * the integration protocol can call its tools directly instead of HTTP.
6
+ * The adapter hides the lookup so `WrongTraceClient` can stay oblivious.
7
+ *
8
+ * MCP discovery is lazy: the adapter does NOT require the MCP SDK at
9
+ * construction time. We accept a `tools` bag from the caller — typically
10
+ * populated from `mcp_control.list()` in the host runtime. If no tools
11
+ * are provided, every call resolves with `null`, same as HTTP/IPC
12
+ * failure paths.
13
+ */
14
+ export function createMcpTransport(tools = {}) {
15
+ const entries = Object.entries(tools).filter(([, v]) => typeof v === "function");
16
+ return {
17
+ isWired: entries.length > 0,
18
+ availableTools: entries.map(([k]) => k),
19
+ async invoke(tool, args) {
20
+ const handler = tools[tool];
21
+ if (!handler)
22
+ return null;
23
+ try {
24
+ return (await handler(args));
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ },
30
+ };
31
+ }
32
+ // Convenience mappers — keep the spec endpoints ↔ MCP tool names in one place
33
+ // so future renames only need to touch this file.
34
+ export const mcp = {
35
+ health(_health) {
36
+ return null; // `/api/health` is HTTP-only by spec
37
+ },
38
+ lockResult(result) {
39
+ return result;
40
+ },
41
+ };
42
+ //# sourceMappingURL=mcp.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Agent-facing helpers — opinionated wrappers over the raw WrongTrace
3
+ * client that turn multi-call queries into single, decision-ready values.
4
+ *
5
+ * Why this file exists:
6
+ * The base client returns rich-but-unopinionated JSON (friction edges,
7
+ * atlas nodes, file health rows). An agent deciding whether to edit a
8
+ * file should not have to fuse those three calls by hand. These helpers
9
+ * produce ONE number, ONE sentence, ONE list — the things an agent or a
10
+ * prompt actually consumes.
11
+ *
12
+ * Every helper degrades gracefully when WrongTrace is offline: it returns
13
+ * a typed "no-op" shape that the caller can wire unconditionally.
14
+ */
15
+ import type { WrongTraceAtlasSummary, WrongTraceClient } from "./types.js";
16
+ /**
17
+ * Combines `getFileHealth(path)` + `getFrictionMatrix()` into one
18
+ * decision-ready score. Returns:
19
+ * - `risk`: 0..100 — how dangerous editing this file is RIGHT NOW
20
+ * - `band`: "safe" | "caution" | "fragile" | "locked" | "unknown"
21
+ * - `reasons`: human-readable bullet list for prompt injection
22
+ *
23
+ * Heuristic (documented so the numbers don't drift silently):
24
+ * - file.is_locked → 100, "locked"
25
+ * - file.health_score <40 → base 80, "fragile"
26
+ * - file.recent_thrashing → +5 per event above 3, cap at +25
27
+ * - friction: this file's
28
+ * author_model vs top
29
+ * overwriter_model → +20 if conflict_count >= 3
30
+ * - all multipliers cap at 100.
31
+ *
32
+ * The `path` → friction join is intentionally fuzzy: the daemon's
33
+ * friction matrix does not always carry a per-file field, so when no
34
+ * row mentions `path`, we attribute only the file-health signals. This
35
+ * keeps the helper robust against daemon schema drift.
36
+ */
37
+ export interface CrossAgentRisk {
38
+ path: string;
39
+ risk: number;
40
+ band: "safe" | "caution" | "fragile" | "locked" | "unknown";
41
+ reasons: string[];
42
+ }
43
+ export declare function getCrossAgentRisk(wt: WrongTraceClient, path: string, frictionLimit?: number,
44
+ /** Caller's lock-owner identity (`wrongstack:<sessionId>`). A lock the
45
+ * caller itself holds (e.g. leaked by an interrupted earlier edit) is
46
+ * exempted — it must not deny the session's own retry. */
47
+ selfOwner?: string): Promise<CrossAgentRisk>;
48
+ /**
49
+ * Condenses `getFrictionMatrix()` into a short, prompt-ready string:
50
+ *
51
+ * "Top friction pair: MiniMax-M3 ↔ gemini-3.7-flash (3 conflicts).
52
+ * Cross-agent ratio: 40% of 10 collisions. Self-thrash: 60%."
53
+ *
54
+ * Returns "" when no signal is available — the caller can drop the
55
+ * block from the prompt without branching.
56
+ */
57
+ export interface FrictionSummary {
58
+ topPair: string | null;
59
+ crossAgentRatioPct: number;
60
+ selfThrashRatioPct: number;
61
+ totalCollisions: number;
62
+ prose: string;
63
+ }
64
+ export declare function summarizeFriction(friction: unknown): FrictionSummary;
65
+ export interface RecentActivityEntry {
66
+ at: string;
67
+ actor: string;
68
+ action: string;
69
+ runId?: string | undefined;
70
+ }
71
+ /**
72
+ * Returns a chronological list of recent activity events for a file,
73
+ * sourcing from both the friction matrix's `recent_collisions` and any
74
+ * dedicated events endpoint exposed by the daemon.
75
+ *
76
+ * Returns [] when no events are available or the daemon is offline —
77
+ * the caller can treat an empty result as "no history known".
78
+ */
79
+ export declare function getRecentActivity(wt: WrongTraceClient, filePath: string, limit?: number): Promise<RecentActivityEntry[]>;
80
+ /**
81
+ * Summarizes `getAtlas()` for boot prompts:
82
+ * - workspace count
83
+ * - fragile-file count (health_score < 40 OR is_fragile)
84
+ * - self-thrash-heavy workspaces (those with >5 recent_thrashing files)
85
+ *
86
+ * Returns null when no atlas is available — the caller can skip
87
+ * the prompt block.
88
+ */
89
+ export interface AtlasDigest {
90
+ workspaceCount: number;
91
+ fragileFileCount: number;
92
+ selfThrashWorkspaces: string[];
93
+ prose: string;
94
+ }
95
+ interface AtlasPackage {
96
+ name: string;
97
+ files?: Array<{
98
+ health_score?: number;
99
+ is_fragile?: boolean;
100
+ recent_thrashing_count?: number;
101
+ }>;
102
+ }
103
+ interface AtlasShape {
104
+ workspaces?: string[];
105
+ packages?: AtlasPackage[];
106
+ }
107
+ export declare function digestAtlas(atlas: WrongTraceAtlasSummary | AtlasShape | null): AtlasDigest | null;
108
+ export {};
109
+ //# sourceMappingURL=agent-helpers.d.ts.map
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Agent-facing helpers — opinionated wrappers over the raw WrongTrace
3
+ * client that turn multi-call queries into single, decision-ready values.
4
+ *
5
+ * Why this file exists:
6
+ * The base client returns rich-but-unopinionated JSON (friction edges,
7
+ * atlas nodes, file health rows). An agent deciding whether to edit a
8
+ * file should not have to fuse those three calls by hand. These helpers
9
+ * produce ONE number, ONE sentence, ONE list — the things an agent or a
10
+ * prompt actually consumes.
11
+ *
12
+ * Every helper degrades gracefully when WrongTrace is offline: it returns
13
+ * a typed "no-op" shape that the caller can wire unconditionally.
14
+ */
15
+ export async function getCrossAgentRisk(wt, path, frictionLimit = 50,
16
+ /** Caller's lock-owner identity (`wrongstack:<sessionId>`). A lock the
17
+ * caller itself holds (e.g. leaked by an interrupted earlier edit) is
18
+ * exempted — it must not deny the session's own retry. */
19
+ selfOwner) {
20
+ if (!wt.isAvailable) {
21
+ return { path, risk: 0, band: "unknown", reasons: ["WrongTrace offline — no signal available"] };
22
+ }
23
+ const health = await wt.getFileHealth(path);
24
+ const friction = await wt.getFrictionMatrix(frictionLimit);
25
+ if (health?.is_locked) {
26
+ // Self-owner exemption: a live lock claimed by THIS session is not a
27
+ // foreign conflict. The acquire happens in preToolUse, so a retry or a
28
+ // leaked own lock must fall through to health scoring instead of
29
+ // hard-blocking the session's own edit path.
30
+ if (selfOwner !== undefined && health.lock_owner === selfOwner) {
31
+ const exempted = await scoreFromHealth(path, health, friction);
32
+ exempted.reasons.unshift(`own lock held (owner ${selfOwner}) — exempted`);
33
+ return exempted;
34
+ }
35
+ const expiresAt = health.lock_expires_at ? Date.parse(health.lock_expires_at) : Number.NaN;
36
+ const hasExpiry = !Number.isNaN(expiresAt);
37
+ // A lock whose TTL already elapsed is stale — the daemon may not have
38
+ // reaped it yet. Treat it as unlocked (fall through to health scoring)
39
+ // so a dead lock can never block an edit forever.
40
+ if (hasExpiry && expiresAt <= Date.now()) {
41
+ // fall through to normal scoring below; note the stale lock.
42
+ const staleReason = `stale lock ignored (expired at ${health.lock_expires_at})`;
43
+ const base = await scoreFromHealth(path, health, friction);
44
+ base.reasons.unshift(staleReason);
45
+ return base;
46
+ }
47
+ const ownerNote = health.lock_owner ? ` by ${health.lock_owner}` : "";
48
+ const reasonNote = health.lock_reason ? `: ${health.lock_reason}` : "";
49
+ const expiryNote = hasExpiry
50
+ ? `, expires ${new Date(expiresAt).toISOString()}`
51
+ : " (no expiry — daemon TTL missing, treat as held)";
52
+ return {
53
+ path,
54
+ risk: 100,
55
+ band: "locked",
56
+ reasons: [`file is locked${ownerNote}${reasonNote}${expiryNote}`],
57
+ };
58
+ }
59
+ if (health) {
60
+ return scoreFromHealth(path, health, friction);
61
+ }
62
+ return { path, risk: 50, band: "unknown", reasons: ["file health endpoint unreachable"] };
63
+ }
64
+ /** Shared scoring used both directly and after a stale-lock fallthrough. */
65
+ async function scoreFromHealth(path, health, friction) {
66
+ const reasons = [];
67
+ let risk = 0;
68
+ if (health.is_fragile || health.health_score < 40) {
69
+ risk = Math.max(risk, 80);
70
+ reasons.push(`file is fragile (health_score=${health.health_score})`);
71
+ }
72
+ else if (health.health_score < 70) {
73
+ risk = Math.max(risk, 45);
74
+ reasons.push(`health_score below 70 (${health.health_score})`);
75
+ }
76
+ if (health.recent_thrashing_count > 3) {
77
+ const thrashPenalty = Math.min(25, (health.recent_thrashing_count - 3) * 5);
78
+ risk = Math.min(100, risk + thrashPenalty);
79
+ reasons.push(`${health.recent_thrashing_count} recent write/delete cycles in last 24h (+${thrashPenalty})`);
80
+ }
81
+ // Path-aware friction lookup: best-effort, daemon schema may not carry file_path.
82
+ const fileFriction = friction.filter((row) => {
83
+ const r = row;
84
+ if (typeof r.file_path === "string")
85
+ return r.file_path === path;
86
+ if (Array.isArray(r.files))
87
+ return r.files.includes(path);
88
+ return false;
89
+ });
90
+ if (fileFriction.length > 0) {
91
+ const totalConflicts = fileFriction.reduce((acc, r) => {
92
+ const raw = r.conflict_count;
93
+ return acc + (typeof raw === "number" ? raw : 0);
94
+ }, 0);
95
+ if (totalConflicts >= 3) {
96
+ risk = Math.min(100, risk + 20);
97
+ reasons.push(`${totalConflicts} cross-agent conflicts on this file in friction matrix (+20)`);
98
+ }
99
+ }
100
+ const band = risk >= 80 ? "fragile" :
101
+ risk >= 50 ? "caution" :
102
+ risk > 0 ? "safe" :
103
+ "safe";
104
+ if (reasons.length === 0)
105
+ reasons.push("no risk signals — file is healthy");
106
+ return { path, risk, band, reasons };
107
+ }
108
+ export function summarizeFriction(friction) {
109
+ const empty = {
110
+ topPair: null,
111
+ crossAgentRatioPct: 0,
112
+ selfThrashRatioPct: 0,
113
+ totalCollisions: 0,
114
+ prose: "",
115
+ };
116
+ if (!friction || typeof friction !== "object")
117
+ return empty;
118
+ const r = friction;
119
+ const edges = Array.isArray(r.edges) ? r.edges : [];
120
+ const total = typeof r.total_collisions === "number" ? r.total_collisions : edges.length;
121
+ if (total === 0)
122
+ return empty;
123
+ // Find top pair by total conflict_count across both directions.
124
+ const pairTotals = new Map();
125
+ for (const e of edges) {
126
+ const key = [e.author_model, e.overwriter_model].sort().join("|");
127
+ const raw = e.conflict_count;
128
+ const c = typeof raw === "number" ? raw : 0;
129
+ const cur = pairTotals.get(key);
130
+ if (cur)
131
+ cur.count = cur.count + c;
132
+ else
133
+ pairTotals.set(key, { count: c, a: e.author_model, b: e.overwriter_model });
134
+ }
135
+ const topEntry = [...pairTotals.values()].sort((x, y) => y.count - x.count)[0];
136
+ const topPair = topEntry ? `${topEntry.a} ↔ ${topEntry.b} (${topEntry.count} conflicts)` : null;
137
+ const selfThrash = edges.filter((e) => e.is_self_thrash).length;
138
+ const crossAgent = total - selfThrash;
139
+ const crossAgentRatioPct = total > 0 ? Math.round((crossAgent / total) * 100) : 0;
140
+ const selfThrashRatioPct = total > 0 ? Math.round((selfThrash / total) * 100) : 0;
141
+ const prose = (topPair ? `Top friction pair: ${topPair}. ` : "") +
142
+ `Cross-agent ratio: ${crossAgentRatioPct}% of ${total} collisions. ` +
143
+ `Self-thrash: ${selfThrashRatioPct}%.`;
144
+ return { topPair, crossAgentRatioPct, selfThrashRatioPct, totalCollisions: total, prose };
145
+ }
146
+ /**
147
+ * Returns a chronological list of recent activity events for a file,
148
+ * sourcing from both the friction matrix's `recent_collisions` and any
149
+ * dedicated events endpoint exposed by the daemon.
150
+ *
151
+ * Returns [] when no events are available or the daemon is offline —
152
+ * the caller can treat an empty result as "no history known".
153
+ */
154
+ export async function getRecentActivity(wt, filePath, limit = 10) {
155
+ if (!wt.isAvailable)
156
+ return [];
157
+ // Friction's recent_collisions is the most reliable signal today.
158
+ // The daemon does not yet expose a per-file events endpoint (see
159
+ // Missing-Endpoints report), so this is our primary source.
160
+ const matrix = (await getFrictionRaw(wt, limit * 5));
161
+ const collisions = Array.isArray(matrix.recent_collisions) ? matrix.recent_collisions : [];
162
+ const events = Array.isArray(matrix.events) ? matrix.events : [];
163
+ const all = [...events, ...collisions];
164
+ const matched = [];
165
+ for (const ev of all) {
166
+ if (typeof ev.file_path !== "string" || ev.file_path !== filePath)
167
+ continue;
168
+ const at = ev.overwriter_time ?? ev.author_time ?? "";
169
+ if (!at)
170
+ continue;
171
+ const entry = {
172
+ at,
173
+ actor: ev.overwriter_model ?? ev.author_model ?? "unknown",
174
+ action: ev.action ?? "MODIFIED",
175
+ };
176
+ const runId = ev.overwriter_run_id ?? ev.author_run_id;
177
+ if (runId)
178
+ entry.runId = runId;
179
+ matched.push(entry);
180
+ }
181
+ matched.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
182
+ return matched.slice(0, limit);
183
+ }
184
+ async function getFrictionRaw(wt, limit) {
185
+ // We piggyback on getFrictionMatrix's underlying call by issuing one HTTP
186
+ // request. Since the client doesn't expose raw access, we read the
187
+ // matrix through a dedicated call. Reusing getFrictionMatrix keeps the
188
+ // helper testable without leaking transport details.
189
+ return wt.getFrictionMatrix(limit);
190
+ }
191
+ export function digestAtlas(atlas) {
192
+ if (!atlas)
193
+ return null;
194
+ const a = atlas;
195
+ const workspaces = Array.isArray(a.workspaces) ? a.workspaces : [];
196
+ const packages = Array.isArray(a.packages) ? a.packages : [];
197
+ let fragileFileCount = 0;
198
+ const thrashCounts = new Map();
199
+ for (const pkg of packages) {
200
+ const files = pkg.files ?? [];
201
+ for (const f of files) {
202
+ if ((f.health_score ?? 100) < 40 || f.is_fragile === true)
203
+ fragileFileCount++;
204
+ const thrash = f.recent_thrashing_count ?? 0;
205
+ if (thrash > 5)
206
+ thrashCounts.set(pkg.name, (thrashCounts.get(pkg.name) ?? 0) + 1);
207
+ }
208
+ }
209
+ const selfThrashWorkspaces = [...thrashCounts.entries()]
210
+ .filter(([, n]) => n > 0)
211
+ .sort((x, y) => y[1] - x[1])
212
+ .slice(0, 5)
213
+ .map(([name]) => name);
214
+ const prose = `Atlas: ${workspaces.length || packages.length} workspaces, ` +
215
+ `${fragileFileCount} fragile files, ` +
216
+ (selfThrashWorkspaces.length > 0
217
+ ? `self-thrash hotspots: ${selfThrashWorkspaces.join(", ")}.`
218
+ : "no self-thrash hotspots.");
219
+ return {
220
+ workspaceCount: workspaces.length || packages.length,
221
+ fragileFileCount,
222
+ selfThrashWorkspaces,
223
+ prose,
224
+ };
225
+ }
226
+ //# sourceMappingURL=agent-helpers.js.map
@@ -0,0 +1,45 @@
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 { type McpToolBag } from "./adapters/mcp.js";
34
+ import { type DiscoveryOptions, type DiscoveryResult } from "./discovery.js";
35
+ import type { WrongTraceClient } from "./types.js";
36
+ export interface WrongTraceClientOptions extends DiscoveryOptions {
37
+ /** Optional MCP tool bag — typically `mcp_control.list()` output. */
38
+ mcpTools?: McpToolBag;
39
+ }
40
+ export interface WrongTraceClientInternal extends WrongTraceClient {
41
+ /** Internal: lets tests + integrations verify what was actually discovered. */
42
+ readonly _discovery: DiscoveryResult;
43
+ }
44
+ export declare function createWrongTraceClient(opts?: WrongTraceClientOptions): Promise<WrongTraceClientInternal>;
45
+ //# sourceMappingURL=client.d.ts.map