ralph-hero-mcp-server 2.5.140 → 2.5.149

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/index.js CHANGED
@@ -31,6 +31,7 @@ import { registerDecomposeTools } from "./tools/decompose-tools.js";
31
31
  import { registerViewTools } from "./tools/view-tools.js";
32
32
  import { registerPlanGraphTools } from "./tools/plan-graph-tools.js";
33
33
  import { registerActivityTools } from "./tools/activity-tools.js";
34
+ import { registerDelegationTools } from "./tools/delegation-tools.js";
34
35
  import { registerTrendsTools } from "./tools/trends-tools.js";
35
36
  /**
36
37
  * Initialize the GitHub client from environment variables.
@@ -445,6 +446,8 @@ async function main() {
445
446
  registerPlanGraphTools(server, client);
446
447
  // Activity log reader (recent_activity tool — pure filesystem, no GitHub client)
447
448
  registerActivityTools(server);
449
+ // Delegation telemetry reader (delegation_stats tool — pure filesystem, no GitHub client)
450
+ registerDelegationTools(server);
448
451
  // Trends tools (capture_snapshot — JSONL persistence under ~/.ralph-hero/snapshots/)
449
452
  registerTrendsTools(server, client, fieldCache);
450
453
  // Debug tools (only when RALPH_DEBUG=true)
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Issue body + comment body builders for `ralph_hero__collate_debug` Phase 3b.
3
+ *
4
+ * Each `SignatureGroup` returned by `groupSpansBySignature` becomes either:
5
+ * - a fresh GitHub issue (when no existing `debug-auto` issue carries the
6
+ * same hash within the dedup window), or
7
+ * - a comment on an existing issue (occurrence-update).
8
+ *
9
+ * The body MUST include a machine-parseable hash marker on its own line —
10
+ * `**Hash**: \`<8-char-hash>\`` — because dedup in Phase 3b matches on this
11
+ * exact line via GitHub's code-search index.
12
+ *
13
+ * Token-shaped values are scrubbed from every emitted field. The regex set
14
+ * mirrors `redactTokenAttributes` in `telemetry.ts`: GitHub tokens
15
+ * (`^gh[ps]_`), basic-auth headers, and any attribute key ending in
16
+ * `_TOKEN` are replaced with `[REDACTED]`.
17
+ */
18
+ // ---------------------------------------------------------------------------
19
+ // Token redaction (kept local to avoid cross-module coupling at runtime; the
20
+ // shape mirrors telemetry.ts:redactTokenAttributes for consistency)
21
+ // ---------------------------------------------------------------------------
22
+ const GH_TOKEN_VALUE_RE = /\bgh[psour]_[A-Za-z0-9_]{16,}\b/g;
23
+ const TOKEN_KEY_RE = /(_TOKEN|authorization)$/i;
24
+ const BASIC_AUTH_RE = /\bBasic\s+[A-Za-z0-9+/=]{8,}\b/g;
25
+ /**
26
+ * Scrub token-shaped substrings from a free-form string. Used for error
27
+ * messages and serialised metadata before they land in an issue body.
28
+ */
29
+ export function scrubTokensFromString(input) {
30
+ if (!input)
31
+ return input;
32
+ return input.replace(GH_TOKEN_VALUE_RE, "[REDACTED]").replace(BASIC_AUTH_RE, "Basic [REDACTED]");
33
+ }
34
+ /**
35
+ * Scrub token-shaped values from a plain attribute bag. Keys matching
36
+ * `_TOKEN` or `authorization` (case-insensitive) are replaced with
37
+ * `[REDACTED]`; values matching the GitHub token regex are scrubbed too.
38
+ *
39
+ * The result is a shallow copy — callers can serialise it without mutating
40
+ * the input. Nested objects are stringified before scrubbing to keep the
41
+ * function flat and predictable.
42
+ */
43
+ export function scrubTokensFromAttrs(attrs) {
44
+ const out = {};
45
+ for (const [key, value] of Object.entries(attrs)) {
46
+ if (TOKEN_KEY_RE.test(key)) {
47
+ out[key] = "[REDACTED]";
48
+ continue;
49
+ }
50
+ if (typeof value === "string") {
51
+ out[key] = scrubTokensFromString(value);
52
+ }
53
+ else if (value !== null && typeof value === "object") {
54
+ out[key] = scrubTokensFromString(JSON.stringify(value));
55
+ }
56
+ else {
57
+ out[key] = value;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // Helpers
64
+ // ---------------------------------------------------------------------------
65
+ const TITLE_MAX = 100;
66
+ const NORMALIZED_MAX = 60;
67
+ function truncate(s, max) {
68
+ if (s.length <= max)
69
+ return s;
70
+ return s.slice(0, max - 1) + "…"; // ellipsis
71
+ }
72
+ /**
73
+ * Pull a short, human-readable error blurb out of a span. Falls back through
74
+ * statusMessage -> exception.message -> metadata.error -> the span name.
75
+ */
76
+ function extractMessage(span) {
77
+ if (!span)
78
+ return "";
79
+ if (span.message)
80
+ return span.message;
81
+ const meta = span.metadata ?? {};
82
+ const exception = meta.exception;
83
+ if (exception &&
84
+ typeof exception === "object" &&
85
+ "message" in exception &&
86
+ typeof exception.message === "string") {
87
+ return exception.message;
88
+ }
89
+ if (typeof meta.error === "string")
90
+ return meta.error;
91
+ if (typeof meta.message === "string")
92
+ return meta.message;
93
+ return "";
94
+ }
95
+ /**
96
+ * Take the third segment of a `${spanName}:${errorType}:${normalized}`
97
+ * signature string. Used to populate the title when no sample span message
98
+ * is available.
99
+ */
100
+ function normalizedFromSignature(signature) {
101
+ const parts = signature.split(":");
102
+ if (parts.length < 3)
103
+ return signature;
104
+ return parts.slice(2).join(":");
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // buildIssueBody
108
+ // ---------------------------------------------------------------------------
109
+ /**
110
+ * Build the title + body for a freshly-filed `debug-auto` issue.
111
+ *
112
+ * The body layout is deliberately stable so Phase 3b's dedup regex
113
+ * (`/^\*\*Hash\*\*: `([0-9a-f]{8})`/m`) keeps matching across versions.
114
+ */
115
+ export function buildIssueBody(group, env) {
116
+ const sample = group.sampleSpans[0];
117
+ const rawMessage = extractMessage(sample) || normalizedFromSignature(group.signature);
118
+ const message = scrubTokensFromString(rawMessage);
119
+ const spanName = sample?.name ?? "ralph_hero.error";
120
+ const title = truncate(`[Debug] ${spanName}: ${truncate(message, NORMALIZED_MAX)}`, TITLE_MAX);
121
+ const occurrenceRows = [
122
+ `| Count | First seen | Last seen |`,
123
+ `|---|---|---|`,
124
+ `| ${group.count} | ${group.firstSeen} | ${group.lastSeen} |`,
125
+ ].join("\n");
126
+ const sampleAttrs = sample?.metadata
127
+ ? scrubTokensFromAttrs(sample.metadata)
128
+ : {};
129
+ const errorDetails = Object.keys(sampleAttrs).length
130
+ ? "```json\n" + JSON.stringify(sampleAttrs, null, 2) + "\n```"
131
+ : "_(no attributes captured on sample span)_";
132
+ const reproduction = sample
133
+ ? "```json\n" +
134
+ JSON.stringify({
135
+ spanName: sample.name,
136
+ traceId: sample.traceId,
137
+ startTime: sample.startTime,
138
+ errorType: sample.errorType,
139
+ message: scrubTokensFromString(extractMessage(sample)),
140
+ }, null, 2) +
141
+ "\n```"
142
+ : "_(no sample span available)_";
143
+ const body = [
144
+ `**Hash**: \`${group.hash}\``,
145
+ ``,
146
+ `**Signature**: \`${scrubTokensFromString(group.signature)}\``,
147
+ ``,
148
+ `## First seen`,
149
+ ``,
150
+ `- mcp-server version: \`${env.mcpVersion}\``,
151
+ `- node: \`${env.nodeVersion}\``,
152
+ `- os: \`${env.os}\``,
153
+ ``,
154
+ `## Error details`,
155
+ ``,
156
+ errorDetails,
157
+ ``,
158
+ `## Reproduction (sample span)`,
159
+ ``,
160
+ reproduction,
161
+ ``,
162
+ `## Occurrences`,
163
+ ``,
164
+ occurrenceRows,
165
+ ``,
166
+ `## Langfuse trace`,
167
+ ``,
168
+ `[Open latest example trace](${group.exampleTraceUrl})`,
169
+ ``,
170
+ `---`,
171
+ ``,
172
+ `_Filed automatically by \`ralph_hero__collate_debug\` — Phase 3b (GH-1100). ` +
173
+ `Re-running collation over the same window will append occurrence ` +
174
+ `comments here instead of creating a duplicate issue._`,
175
+ ].join("\n");
176
+ return { title, body };
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // buildCommentBody
180
+ // ---------------------------------------------------------------------------
181
+ /**
182
+ * Build the occurrence-update comment body posted when an existing
183
+ * `debug-auto` issue is matched by hash. `newCount` is the count returned by
184
+ * the current `groupSpansBySignature` run — i.e., occurrences in the *new*
185
+ * window, not the cumulative total (we don't have read access to historical
186
+ * comment counts without extra queries).
187
+ */
188
+ export function buildCommentBody(group, newCount, latestTraceUrl) {
189
+ return [
190
+ `## Recurring occurrence`,
191
+ ``,
192
+ `Detected **${newCount}** new occurrence${newCount === 1 ? "" : "s"} of this signature in the latest collation window.`,
193
+ ``,
194
+ `- Hash: \`${group.hash}\``,
195
+ `- First seen (this window): ${group.firstSeen}`,
196
+ `- Last seen (this window): ${group.lastSeen}`,
197
+ `- [Latest example trace](${scrubTokensFromString(latestTraceUrl)})`,
198
+ ``,
199
+ `_Posted automatically by \`ralph_hero__collate_debug\` (Phase 3b)._`,
200
+ ].join("\n");
201
+ }
202
+ //# sourceMappingURL=debug-issue-shape.js.map
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Pure read library for the local ralph-delegate JSONL audit log.
3
+ *
4
+ * The log lives at `~/.ralph-hero/delegate.log` (overridable via
5
+ * `RALPH_DELEGATE_LOG_PATH`). One JSON object per line, append-only,
6
+ * written by `plugin/ralph-hero/scripts/ralph-delegate.sh`. This library
7
+ * only reads — it never writes.
8
+ *
9
+ * Schema versioning: F1 (issue #1185) does NOT emit an explicit
10
+ * `schemaVersion` field on each line. F5 treats lines containing the
11
+ * required fields `{ts, task, status, ms}` as **implicit v1**. A future
12
+ * issue MAY add an explicit `schemaVersion >= 2` — when that happens, the
13
+ * shape-check should be expanded to honor it. TODO: revisit when the
14
+ * producer side emits `schemaVersion`.
15
+ *
16
+ * Determinism: pure functions. Filesystem reads are the only side effect.
17
+ * Missing log file resolves to a zero-state result with no throw, matching
18
+ * the activity.ts precedent (the steady state for opt-out users).
19
+ */
20
+ import * as fs from "node:fs/promises";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+ // ---------------------------------------------------------------------------
24
+ // Test hook + default path resolution
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * Optional override used only by tests. Production code never sets this.
28
+ * Mirrors `__setSnapshotRoot` in `snapshots.ts`.
29
+ */
30
+ let delegateLogPathOverride = null;
31
+ /** Test hook: override the default log path. Pass `null` to restore. */
32
+ export function __setDelegateLogPath(path) {
33
+ delegateLogPathOverride = path;
34
+ }
35
+ /**
36
+ * Resolve the default log path: env var override, then `~/.ralph-hero/delegate.log`.
37
+ * Expands a leading `~/` (mirrors `ralph-delegate.sh:208-211`).
38
+ */
39
+ export function defaultDelegationLogPath() {
40
+ if (delegateLogPathOverride !== null)
41
+ return delegateLogPathOverride;
42
+ const fromEnv = process.env.RALPH_DELEGATE_LOG_PATH;
43
+ if (fromEnv && fromEnv.length > 0) {
44
+ return expandHome(fromEnv);
45
+ }
46
+ return path.join(os.homedir(), ".ralph-hero", "delegate.log");
47
+ }
48
+ function expandHome(p) {
49
+ if (p.startsWith("~/")) {
50
+ return path.join(os.homedir(), p.slice(2));
51
+ }
52
+ return p;
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Reader
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Read the delegation audit log. Returns a zero-state result with
59
+ * `fileExists: false` when the file is missing — never throws on ENOENT.
60
+ * Lines that fail JSON.parse OR lack required fields are skipped and
61
+ * counted in `skippedLines`; each skip emits a `console.warn` with a
62
+ * truncated prefix of the offending line.
63
+ */
64
+ export async function readDelegationLog(config) {
65
+ const logPath = config.logPath;
66
+ let content;
67
+ try {
68
+ content = await fs.readFile(logPath, "utf8");
69
+ }
70
+ catch (e) {
71
+ if (e &&
72
+ typeof e === "object" &&
73
+ "code" in e &&
74
+ e.code === "ENOENT") {
75
+ return { events: [], skippedLines: 0, fileExists: false, logPath };
76
+ }
77
+ throw e;
78
+ }
79
+ const events = [];
80
+ let skipped = 0;
81
+ for (const raw of content.split("\n")) {
82
+ const line = raw.trim();
83
+ if (line.length === 0)
84
+ continue;
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(line);
88
+ }
89
+ catch {
90
+ skipped++;
91
+ console.warn(`[delegation-log] Skipping malformed line in ${logPath}: ${line.slice(0, 80)}`);
92
+ continue;
93
+ }
94
+ if (!isDelegationEventShape(parsed)) {
95
+ skipped++;
96
+ console.warn(`[delegation-log] Skipping line with missing required fields in ${logPath}: ${line.slice(0, 80)}`);
97
+ continue;
98
+ }
99
+ events.push(parsed);
100
+ }
101
+ return { events, skippedLines: skipped, fileExists: true, logPath };
102
+ }
103
+ /**
104
+ * Implicit-v1 shape check: presence of `{ts, task, status, ms}` with
105
+ * correct primitive types. A future explicit `schemaVersion >= 2` may
106
+ * extend this gate.
107
+ */
108
+ function isDelegationEventShape(v) {
109
+ if (!v || typeof v !== "object")
110
+ return false;
111
+ const o = v;
112
+ return (typeof o.ts === "string" &&
113
+ typeof o.task === "string" &&
114
+ typeof o.status === "string" &&
115
+ typeof o.ms === "number");
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Aggregator
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Aggregate parsed events into per-task + totals. Pure function — does
122
+ * no I/O. Percentiles use the nearest-rank method against successful
123
+ * (status=ok) calls only.
124
+ */
125
+ export function aggregateDelegationStats(events) {
126
+ const byTask = {};
127
+ const okMsByTask = {};
128
+ let totalCalls = 0;
129
+ let totalFallbacks = 0;
130
+ let totalBytesIn = 0;
131
+ let totalBytesOut = 0;
132
+ for (const ev of events) {
133
+ const task = ev.task;
134
+ if (!byTask[task]) {
135
+ byTask[task] = {
136
+ calls: 0,
137
+ fallbacks: 0,
138
+ p50Ms: null,
139
+ p99Ms: null,
140
+ bytesIn: 0,
141
+ bytesOut: 0,
142
+ tokens: null,
143
+ };
144
+ okMsByTask[task] = [];
145
+ }
146
+ byTask[task].calls += 1;
147
+ totalCalls += 1;
148
+ if (isFallbackStatus(ev.status)) {
149
+ byTask[task].fallbacks += 1;
150
+ totalFallbacks += 1;
151
+ }
152
+ const bIn = typeof ev.bytes_in === "number" ? ev.bytes_in : 0;
153
+ const bOut = typeof ev.bytes_out === "number" ? ev.bytes_out : 0;
154
+ byTask[task].bytesIn += bIn;
155
+ byTask[task].bytesOut += bOut;
156
+ totalBytesIn += bIn;
157
+ totalBytesOut += bOut;
158
+ if (ev.status === "ok") {
159
+ okMsByTask[task].push(ev.ms);
160
+ }
161
+ }
162
+ // Compute percentiles
163
+ for (const task of Object.keys(byTask)) {
164
+ const samples = okMsByTask[task];
165
+ byTask[task].p50Ms = percentile(samples, 0.5);
166
+ byTask[task].p99Ms = percentile(samples, 0.99);
167
+ }
168
+ return {
169
+ totals: {
170
+ calls: totalCalls,
171
+ fallbacks: totalFallbacks,
172
+ bytesIn: totalBytesIn,
173
+ bytesOut: totalBytesOut,
174
+ },
175
+ byTask,
176
+ };
177
+ }
178
+ /**
179
+ * A fallback is any non-`ok`, non-`dry_run` status: timeout, unreachable,
180
+ * parse_error, http_*. `dry_run` is operator-driven and not a real
181
+ * delegation failure, so it does not count.
182
+ */
183
+ function isFallbackStatus(status) {
184
+ return status !== "ok" && status !== "dry_run";
185
+ }
186
+ /**
187
+ * Nearest-rank percentile: `sorted[ceil(q * n) - 1]`. Returns `null`
188
+ * when the sample is empty.
189
+ */
190
+ function percentile(samples, q) {
191
+ if (samples.length === 0)
192
+ return null;
193
+ const sorted = [...samples].sort((a, b) => a - b);
194
+ const rank = Math.ceil(q * sorted.length);
195
+ // Guard the edge case q=0 (would index -1)
196
+ const idx = Math.max(0, rank - 1);
197
+ return sorted[idx];
198
+ }
199
+ //# sourceMappingURL=delegation-log.js.map
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Provides:
5
5
  * - `ralph_hero__collate_debug` (v2 — queries Langfuse for error spans,
6
- * groups by normalized signature, returns the grouped report; GitHub
7
- * issue creation lands in Phase 3b / GH-1100)
6
+ * groups by normalized signature, dedupes against open `debug-auto`
7
+ * issues, and either creates new issues or appends occurrence comments
8
+ * when `dryRun=false`)
8
9
  * - `ralph_hero__debug_stats` (v1 — aggregates JSONL logs; preserved for
9
10
  * backward compat, not extended)
10
11
  *
@@ -12,14 +13,18 @@
12
13
  * `debug_stats`; the new Langfuse path is fully separate.
13
14
  */
14
15
  import { readdir, readFile } from "node:fs/promises";
15
- import { join } from "node:path";
16
- import { homedir } from "node:os";
16
+ import { readFileSync } from "node:fs";
17
+ import { join, resolve, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { homedir, platform, release } from "node:os";
17
20
  import { createHash } from "node:crypto";
18
21
  import { z } from "zod";
19
22
  import { toolSuccess, toolError } from "../types.js";
20
23
  import { zBoolish } from "../lib/zod-helpers.js";
21
24
  import { createLangfuseClient, } from "../lib/langfuse-client.js";
22
25
  import { groupSpansBySignature, observationToSpan, } from "../lib/error-signature.js";
26
+ import { buildIssueBody, buildCommentBody, } from "../lib/debug-issue-shape.js";
27
+ import { resolveConfig } from "../lib/helpers.js";
23
28
  // ---------------------------------------------------------------------------
24
29
  // JSONL Parsing
25
30
  // ---------------------------------------------------------------------------
@@ -181,6 +186,208 @@ export function aggregateStats(events, groupBy) {
181
186
  groups,
182
187
  };
183
188
  }
189
+ /**
190
+ * Look for an open `debug-auto` issue whose body carries the given 8-char
191
+ * hash on a `**Hash**: \`<hash>\`` line. Only issues updated within the last
192
+ * `withinDays` are considered (default 7), matching the spec's "dedup window".
193
+ *
194
+ * Returns the first matching issue `{ number, id }` or `null` if no match.
195
+ * Search rate-limit errors are swallowed (caller will create a duplicate;
196
+ * the next run will collapse it via comment).
197
+ */
198
+ export async function findExistingDebugIssue(client, owner, repo, hash, withinDays = 7) {
199
+ const sinceIso = new Date(Date.now() - withinDays * 24 * 60 * 60 * 1000)
200
+ .toISOString()
201
+ .slice(0, 10); // YYYY-MM-DD
202
+ // The hash marker `**Hash**: ` is too punctuation-heavy for GitHub's text
203
+ // search index — search on the bare 8-char hex; the marker line is verified
204
+ // by inspecting the issue body below.
205
+ const q = `repo:${owner}/${repo} is:issue is:open label:debug-auto ${hash} in:body updated:>=${sinceIso}`;
206
+ try {
207
+ const data = await client.query(`query DebugIssueSearch($q: String!) {
208
+ search(query: $q, type: ISSUE, first: 10) {
209
+ nodes {
210
+ ... on Issue {
211
+ number
212
+ id
213
+ body
214
+ }
215
+ }
216
+ }
217
+ }`, { q });
218
+ const marker = new RegExp(`^\\*\\*Hash\\*\\*: \`${hash}\``, "m");
219
+ for (const node of data.search.nodes ?? []) {
220
+ if (typeof node.number === "number" &&
221
+ typeof node.id === "string" &&
222
+ typeof node.body === "string" &&
223
+ marker.test(node.body)) {
224
+ return { number: node.number, id: node.id };
225
+ }
226
+ }
227
+ return null;
228
+ }
229
+ catch (error) {
230
+ console.error(`[debug-tools] findExistingDebugIssue search failed (treating as no-match): ${error instanceof Error ? error.message : String(error)}`);
231
+ return null;
232
+ }
233
+ }
234
+ /**
235
+ * Resolve the repository's GraphQL node ID, with SessionCache memoization.
236
+ */
237
+ async function resolveRepoNodeId(client, owner, repo) {
238
+ const cacheKey = `repo-node-id:${owner}/${repo}`;
239
+ const cached = client.getCache().get(cacheKey);
240
+ if (cached)
241
+ return cached;
242
+ const result = await client.query(`query($owner: String!, $repo: String!) {
243
+ repository(owner: $owner, name: $repo) { id }
244
+ }`, { owner, repo });
245
+ const id = result.repository?.id;
246
+ if (!id) {
247
+ throw new Error(`Repository ${owner}/${repo} not found`);
248
+ }
249
+ client.getCache().set(cacheKey, id, 60 * 60 * 1000); // 1 hour
250
+ return id;
251
+ }
252
+ /**
253
+ * Resolve repo-scoped label IDs for `debug-auto` and `ralph-self-report`.
254
+ * Labels that don't exist in the repo are skipped silently — issue creation
255
+ * still proceeds, the label just won't be applied.
256
+ */
257
+ async function resolveLabelIds(client, owner, repo, labelNames) {
258
+ const cacheKey = `repo-labels:${owner}/${repo}`;
259
+ let labels = client.getCache().get(cacheKey);
260
+ if (!labels) {
261
+ const result = await client.query(`query($owner: String!, $repo: String!) {
262
+ repository(owner: $owner, name: $repo) {
263
+ labels(first: 100) { nodes { id name } }
264
+ }
265
+ }`, { owner, repo });
266
+ labels = result.repository?.labels.nodes ?? [];
267
+ client.getCache().set(cacheKey, labels, 5 * 60 * 1000);
268
+ }
269
+ return labelNames
270
+ .map((n) => labels.find((l) => l.name === n)?.id)
271
+ .filter((id) => typeof id === "string");
272
+ }
273
+ /**
274
+ * Create a fresh `debug-auto` issue for a new signature. Returns the new
275
+ * issue number + node ID. Project board placement (Backlog state) is
276
+ * delegated to the existing route-issues.yml workflow — we set labels and
277
+ * the body marker; the workflow handles board routing.
278
+ */
279
+ async function createDebugIssue(client, owner, repo, title, body) {
280
+ const repoId = await resolveRepoNodeId(client, owner, repo);
281
+ const labelIds = await resolveLabelIds(client, owner, repo, [
282
+ "debug-auto",
283
+ "ralph-self-report",
284
+ ]);
285
+ const result = await client.mutate(`mutation($repoId: ID!, $title: String!, $body: String!, $labelIds: [ID!]) {
286
+ createIssue(input: {
287
+ repositoryId: $repoId,
288
+ title: $title,
289
+ body: $body,
290
+ labelIds: $labelIds
291
+ }) {
292
+ issue { id number url }
293
+ }
294
+ }`, {
295
+ repoId,
296
+ title,
297
+ body,
298
+ labelIds: labelIds.length ? labelIds : null,
299
+ });
300
+ const issue = result.createIssue.issue;
301
+ client
302
+ .getCache()
303
+ .set(`issue-node-id:${owner}/${repo}#${issue.number}`, issue.id, 30 * 60 * 1000);
304
+ return issue;
305
+ }
306
+ /**
307
+ * Append an occurrence-update comment to an existing `debug-auto` issue.
308
+ */
309
+ async function commentOnDebugIssue(client, issueNodeId, body) {
310
+ const result = await client.mutate(`mutation($subjectId: ID!, $body: String!) {
311
+ addComment(input: { subjectId: $subjectId, body: $body }) {
312
+ commentEdge { node { id } }
313
+ }
314
+ }`, { subjectId: issueNodeId, body });
315
+ return result.addComment.commentEdge.node.id;
316
+ }
317
+ /**
318
+ * Read the MCP server semver from package.json next to this module. Falls
319
+ * back to `"unknown"` if the file is missing or unreadable. Mirrors the
320
+ * approach used in `telemetry.ts:resolveServiceVersion` but kept local so
321
+ * the debug surface has zero cross-dependency on telemetry init order.
322
+ */
323
+ function readMcpServerVersion() {
324
+ try {
325
+ const here = dirname(fileURLToPath(import.meta.url));
326
+ const pkgPath = resolve(here, "..", "..", "package.json");
327
+ const raw = readFileSync(pkgPath, "utf8");
328
+ const pkg = JSON.parse(raw);
329
+ return pkg.version ?? "unknown";
330
+ }
331
+ catch {
332
+ return "unknown";
333
+ }
334
+ }
335
+ /**
336
+ * Default `IssueShapeEnv` builder — captures the MCP server version, the
337
+ * Node version, and a short OS descriptor at call time. Exposed so tests
338
+ * can override (e.g., deterministic version stamps).
339
+ */
340
+ function defaultEnv(mcpVersion) {
341
+ return {
342
+ mcpVersion: mcpVersion === "unknown" ? readMcpServerVersion() : mcpVersion,
343
+ nodeVersion: process.version,
344
+ os: `${platform()} ${release()}`,
345
+ };
346
+ }
347
+ /**
348
+ * Iterate groups, dedupe each against existing issues, and either create a
349
+ * new issue or post a comment. Returns the counts the tool surfaces back to
350
+ * the caller. Per-group failures are recorded and surfaced but do NOT abort
351
+ * the loop — partial success is preferable to losing the whole run.
352
+ */
353
+ export async function fileOrCommentForGroups(client, owner, repo, groups, env) {
354
+ const results = [];
355
+ let issuesCreated = 0;
356
+ let issuesUpdated = 0;
357
+ for (const group of groups) {
358
+ try {
359
+ const existing = await findExistingDebugIssue(client, owner, repo, group.hash);
360
+ if (existing) {
361
+ const commentBody = buildCommentBody(group, group.count, group.exampleTraceUrl);
362
+ await commentOnDebugIssue(client, existing.id, commentBody);
363
+ issuesUpdated += 1;
364
+ results.push({
365
+ hash: group.hash,
366
+ action: "commented",
367
+ issueNumber: existing.number,
368
+ });
369
+ }
370
+ else {
371
+ const { title, body } = buildIssueBody(group, env);
372
+ const created = await createDebugIssue(client, owner, repo, title, body);
373
+ issuesCreated += 1;
374
+ results.push({
375
+ hash: group.hash,
376
+ action: "created",
377
+ issueNumber: created.number,
378
+ });
379
+ }
380
+ }
381
+ catch (error) {
382
+ results.push({
383
+ hash: group.hash,
384
+ action: "error",
385
+ error: error instanceof Error ? error.message : String(error),
386
+ });
387
+ }
388
+ }
389
+ return { issuesCreated, issuesUpdated, results };
390
+ }
184
391
  let langfuseClientFactory = () => createLangfuseClient();
185
392
  /**
186
393
  * Override the Langfuse client factory. Returns a disposer that restores the
@@ -193,23 +400,20 @@ export function setLangfuseClientFactory(factory) {
193
400
  langfuseClientFactory = prev;
194
401
  };
195
402
  }
196
- export function registerDebugTools(server, client) {
403
+ export function registerDebugTools(server, client, mcpVersion = "unknown") {
197
404
  const logDir = join(homedir(), ".ralph-hero", "logs");
198
- // `client` is referenced by `debug_stats` (legacy) and reserved for Phase 3b
199
- // (GH-1100), which will use it for GitHub dedup + issue creation.
200
- void client;
201
405
  // -------------------------------------------------------------------------
202
- // ralph_hero__collate_debug (v2 — Langfuse path)
406
+ // ralph_hero__collate_debug (v2 — Langfuse + GitHub dedup)
203
407
  // -------------------------------------------------------------------------
204
- server.tool("ralph_hero__collate_debug", "Query Langfuse for error spans in a time window, normalize messages, and group by signature. Phase 3a returns the grouped report only (dryRun forced true); Phase 3b (GH-1100) adds GitHub issue dedup + create/comment. Returns: { since, errorGroups, totalOccurrences, dryRun, groups[] }.", {
408
+ server.tool("ralph_hero__collate_debug", "Query Langfuse for error spans in a time window, normalize messages, group by signature, then either return the grouped report (dryRun=true) or dedupe against open `debug-auto` issues and create / comment (dryRun=false, default). Returns: { since, errorGroups, totalOccurrences, dryRun, issuesCreated?, issuesUpdated?, groups[] }.", {
205
409
  since: z
206
410
  .string()
207
411
  .optional()
208
412
  .describe("ISO date string. Only spans whose startTime >= this value are considered (default: 24h ago)."),
209
413
  dryRun: zBoolish()
210
414
  .optional()
211
- .default(true)
212
- .describe("Phase 3a only honors dryRun=true; passing false returns a stub error until Phase 3b lands."),
415
+ .default(false)
416
+ .describe("If true, return the grouped report without touching GitHub. Default false creates / comments on `debug-auto` issues per signature."),
213
417
  minOccurrences: z
214
418
  .number()
215
419
  .int()
@@ -220,13 +424,10 @@ export function registerDebugTools(server, client) {
220
424
  projectNumber: z
221
425
  .number()
222
426
  .optional()
223
- .describe("Project number override (reserved for Phase 3b)."),
427
+ .describe("Project number override. Currently informational issues land in the configured project via the existing route-issues workflow."),
224
428
  }, async (args) => {
225
429
  try {
226
- const dryRun = args.dryRun ?? true;
227
- if (!dryRun) {
228
- return toolError("dryRun=false requires GH-1100 (Phase 3b) — not yet implemented");
229
- }
430
+ const dryRun = args.dryRun ?? false;
230
431
  const minOccurrences = args.minOccurrences ?? 3;
231
432
  const sinceDate = args.since
232
433
  ? new Date(args.since)
@@ -254,20 +455,46 @@ export function registerDebugTools(server, client) {
254
455
  langfuseHost: langfuse.host,
255
456
  });
256
457
  const totalOccurrences = groups.reduce((sum, g) => sum + g.count, 0);
458
+ const summaryGroups = groups.map((g) => ({
459
+ signature: g.signature,
460
+ hash: g.hash,
461
+ count: g.count,
462
+ firstSeen: g.firstSeen,
463
+ lastSeen: g.lastSeen,
464
+ exampleTraceUrl: g.exampleTraceUrl,
465
+ sampleSpans: g.sampleSpans.slice(0, 3),
466
+ }));
467
+ if (dryRun) {
468
+ return toolSuccess({
469
+ since: fromStartTime,
470
+ errorGroups: groups.length,
471
+ totalOccurrences,
472
+ dryRun: true,
473
+ groups: summaryGroups,
474
+ });
475
+ }
476
+ // dryRun=false — file or comment per signature.
477
+ let owner;
478
+ let repo;
479
+ try {
480
+ const resolved = resolveConfig(client, {});
481
+ owner = resolved.owner;
482
+ repo = resolved.repo;
483
+ }
484
+ catch (error) {
485
+ return toolError(`Cannot resolve owner/repo for issue creation: ${error instanceof Error ? error.message : String(error)}`);
486
+ }
487
+ const env = defaultEnv(mcpVersion);
488
+ const fileResult = await fileOrCommentForGroups(client, owner, repo, groups, env);
257
489
  return toolSuccess({
258
490
  since: fromStartTime,
259
491
  errorGroups: groups.length,
260
492
  totalOccurrences,
261
- dryRun: true,
262
- groups: groups.map((g) => ({
263
- signature: g.signature,
264
- hash: g.hash,
265
- count: g.count,
266
- firstSeen: g.firstSeen,
267
- lastSeen: g.lastSeen,
268
- exampleTraceUrl: g.exampleTraceUrl,
269
- sampleSpans: g.sampleSpans.slice(0, 3),
270
- })),
493
+ dryRun: false,
494
+ issuesCreated: fileResult.issuesCreated,
495
+ issuesUpdated: fileResult.issuesUpdated,
496
+ results: fileResult.results,
497
+ groups: summaryGroups,
271
498
  });
272
499
  }
273
500
  catch (error) {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Registers the `ralph_hero__delegation_stats` MCP tool. Pure read-only
3
+ * surface over the JSONL audit log written by `ralph-delegate.sh` (F1).
4
+ *
5
+ * Follows the same registration convention as `activity-tools.ts`: no
6
+ * GitHub client argument, defaults pulled from env var with a homedir
7
+ * fallback, returns `toolSuccess` on missing-file (zero-state) so callers
8
+ * can render a dashboard without an error path.
9
+ */
10
+ import { z } from "zod";
11
+ import { readDelegationLog, aggregateDelegationStats, defaultDelegationLogPath, } from "../lib/delegation-log.js";
12
+ import { toolSuccess, toolError } from "../types.js";
13
+ const TOKENS_REASON = "F1 audit-log does not capture token usage; bytes used as a proxy";
14
+ export function registerDelegationTools(server) {
15
+ server.tool("ralph_hero__delegation_stats", "Read-only telemetry over the local ralph-delegate JSONL audit log. Returns per-task call counts, fallback counts (non-ok/non-dry_run), p50/p99 latency from successful calls, and bytes_in/bytes_out aggregates. Reads RALPH_DELEGATE_LOG_PATH (default ~/.ralph-hero/delegate.log). Missing log file returns a zero-state result, never errors.", {
16
+ logPath: z
17
+ .string()
18
+ .optional()
19
+ .describe("Optional override for the JSONL log path. Defaults to RALPH_DELEGATE_LOG_PATH or ~/.ralph-hero/delegate.log."),
20
+ }, async (params) => {
21
+ try {
22
+ const resolvedPath = params.logPath ?? defaultDelegationLogPath();
23
+ const read = await readDelegationLog({ logPath: resolvedPath });
24
+ const stats = aggregateDelegationStats(read.events);
25
+ return toolSuccess({
26
+ logPath: read.logPath,
27
+ fileExists: read.fileExists,
28
+ totals: {
29
+ calls: stats.totals.calls,
30
+ fallbacks: stats.totals.fallbacks,
31
+ bytesIn: stats.totals.bytesIn,
32
+ bytesOut: stats.totals.bytesOut,
33
+ skippedLines: read.skippedLines,
34
+ },
35
+ byTask: stats.byTask,
36
+ tokensReason: TOKENS_REASON,
37
+ });
38
+ }
39
+ catch (err) {
40
+ return toolError(err instanceof Error ? err.message : String(err));
41
+ }
42
+ });
43
+ }
44
+ //# sourceMappingURL=delegation-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.140",
3
+ "version": "2.5.149",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",