ralph-hero-mcp-server 2.5.73 → 2.5.75

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
@@ -20,6 +20,7 @@ import { registerProjectTools } from "./tools/project-tools.js";
20
20
  import { registerIssueTools } from "./tools/issue-tools.js";
21
21
  import { registerRelationshipTools } from "./tools/relationship-tools.js";
22
22
  import { registerDashboardTools } from "./tools/dashboard-tools.js";
23
+ import { registerDirectionsTools } from "./tools/directions-tools.js";
23
24
  import { registerBatchTools } from "./tools/batch-tools.js";
24
25
  import { registerProjectManagementTools } from "./tools/project-management-tools.js";
25
26
  import { registerHygieneTools } from "./tools/hygiene-tools.js";
@@ -386,6 +387,8 @@ async function main() {
386
387
  registerRelationshipTools(server, client, fieldCache);
387
388
  // Dashboard and pipeline visualization tools
388
389
  registerDashboardTools(server, client, fieldCache);
390
+ // Hello directions tool — deterministic ranked action items
391
+ registerDirectionsTools(server, client, fieldCache);
389
392
  // Phase 5: Batch operations
390
393
  registerBatchTools(server, client, fieldCache);
391
394
  // Project management tools (archive, remove, add, link repo, clear field)
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Pure ranker library for the `ralph_hero__hello_directions` MCP tool.
3
+ *
4
+ * Computes up to N deterministic directions for a session-briefing
5
+ * companion. All functions are side-effect free: time is injected via
6
+ * `RankConfig.now`, no Date.now() / Math.random() are called inside the
7
+ * module. Inputs are arrays of `DashboardItem` (already fetched + shaped
8
+ * by `tools/dashboard-tools.ts`) plus an optional list of open PRs.
9
+ *
10
+ * Ranking algorithm (lower score wins):
11
+ *
12
+ * score(item) =
13
+ * priorityScore(item.priority) // P0=0, P1=10, P2=20, P3=30, none=99
14
+ * + phaseScore(item.workflowState) // Plan in Review=0, In Review=1, Ready for Plan=2, Research Needed=3
15
+ * + staleBoost(item, now) // -50 if non-lock state with updatedAt > stuckThresholdHours
16
+ * + lockStaleBoost(item, now) // -100 if lock state with updatedAt > lockStaleHours
17
+ * + treeContinueBoost(item, allItems) // -75 if tree-continue criteria match
18
+ *
19
+ * candidates = items
20
+ * .filter(actionable phase OR lock-stale)
21
+ * .filter(no open trackedIssues blocking) // unless candidate would be empty
22
+ * .sort(by score ascending)
23
+ * .promote(tree-continue from top-5 to slot 2 if not slot 1)
24
+ * .merge(open PR scores)
25
+ * .slice(config.limit)
26
+ *
27
+ * Kind precedence per item: lock-stale > tree-continue > issue.
28
+ * PRs are scored separately and only merged into the final ranking.
29
+ */
30
+ import { LOCK_STATES, STATE_ORDER } from "./workflow-states.js";
31
+ export const DEFAULT_RANK_CONFIG = {
32
+ limit: 3,
33
+ stuckThresholdHours: 48,
34
+ lockStaleHours: 24,
35
+ treeRecentDoneDays: 7,
36
+ prStaleHours: 24,
37
+ };
38
+ // ---------------------------------------------------------------------------
39
+ // Internal scoring constants
40
+ // ---------------------------------------------------------------------------
41
+ const ACTIONABLE_PHASES = new Set([
42
+ "Plan in Review",
43
+ "In Review",
44
+ "Ready for Plan",
45
+ "Research Needed",
46
+ ]);
47
+ const PHASE_RANK = {
48
+ "Plan in Review": 0,
49
+ "In Review": 1,
50
+ "Ready for Plan": 2,
51
+ "Research Needed": 3,
52
+ };
53
+ const PRIORITY_RANK = {
54
+ P0: 0,
55
+ P1: 10,
56
+ P2: 20,
57
+ P3: 30,
58
+ };
59
+ const STALE_BOOST = -50;
60
+ const LOCK_STALE_BOOST = -100;
61
+ const TREE_CONTINUE_BOOST = -75;
62
+ const PR_REVIEW_REQUIRED_BOOST = -200;
63
+ const HOUR_MS = 60 * 60 * 1000;
64
+ const DAY_MS = 24 * HOUR_MS;
65
+ // ---------------------------------------------------------------------------
66
+ // Helpers
67
+ // ---------------------------------------------------------------------------
68
+ function priorityScore(p) {
69
+ if (p === null)
70
+ return 99;
71
+ return PRIORITY_RANK[p] ?? 99;
72
+ }
73
+ function phaseScore(state) {
74
+ if (state === null)
75
+ return 99;
76
+ const explicit = PHASE_RANK[state];
77
+ if (explicit !== undefined)
78
+ return explicit;
79
+ // Fallback: for actionable phases not in the explicit table, use their
80
+ // STATE_ORDER position as a coarse tiebreaker so newly-added states
81
+ // still rank reasonably without code changes.
82
+ const idx = STATE_ORDER.indexOf(state);
83
+ return idx >= 0 ? 50 + idx : 99;
84
+ }
85
+ function ageHours(updatedAt, now) {
86
+ const t = new Date(updatedAt).getTime();
87
+ if (Number.isNaN(t))
88
+ return 0;
89
+ return Math.max(0, (now.getTime() - t) / HOUR_MS);
90
+ }
91
+ function ageDays(updatedAt, now) {
92
+ const t = new Date(updatedAt).getTime();
93
+ if (Number.isNaN(t))
94
+ return 0;
95
+ return Math.max(0, (now.getTime() - t) / DAY_MS);
96
+ }
97
+ function hasOpenBlockers(item) {
98
+ return item.blockedBy.some((b) => b.workflowState !== "Done" && b.workflowState !== "Canceled");
99
+ }
100
+ function isLockState(state) {
101
+ return state !== null && LOCK_STATES.includes(state);
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Detection helpers
105
+ // ---------------------------------------------------------------------------
106
+ /**
107
+ * Returns true when the candidate is in a lock state and its updatedAt
108
+ * timestamp is older than `config.lockStaleHours`. These items are surfaced
109
+ * as `kind: "lock-stale"` so the user is reminded to unstick them.
110
+ */
111
+ export function detectLockStale(item, config) {
112
+ if (!isLockState(item.workflowState))
113
+ return false;
114
+ return ageHours(item.updatedAt, config.now) >= config.lockStaleHours;
115
+ }
116
+ /**
117
+ * Returns true when the candidate participates in an "active tree" — i.e.
118
+ * something in its sibling group recently moved or it itself moved while
119
+ * other siblings remain open. False when there is no parent edge or the
120
+ * parent is closed (CLOSED state means tree is finished).
121
+ *
122
+ * Criteria (any one positive):
123
+ * (a) A sibling has closedAt within `treeRecentDoneDays`.
124
+ * (b) The candidate itself has updatedAt within `treeRecentDoneDays`,
125
+ * its parent is open, and at least one other open sibling exists.
126
+ */
127
+ export function detectTreeContinue(item, allItems, config) {
128
+ const parent = item.parentNumber ?? null;
129
+ if (parent === null || parent === undefined)
130
+ return false;
131
+ // Parent done -> tree is finished, do not surface.
132
+ // GitHub raw state arrives as "OPEN" / "CLOSED"; defensively also accept
133
+ // workflow-state strings.
134
+ const parentState = item.parentState ?? null;
135
+ if (parentState === "CLOSED" ||
136
+ parentState === "Done" ||
137
+ parentState === "Canceled") {
138
+ return false;
139
+ }
140
+ const siblings = allItems.filter((other) => other.number !== item.number && other.parentNumber === parent);
141
+ // (a) sibling closed within window
142
+ for (const sib of siblings) {
143
+ if (sib.closedAt) {
144
+ const days = ageDays(sib.closedAt, config.now);
145
+ if (days <= config.treeRecentDoneDays)
146
+ return true;
147
+ }
148
+ }
149
+ // (b) candidate moved recently AND has open siblings
150
+ const candidateRecentlyMoved = ageDays(item.updatedAt, config.now) <= config.treeRecentDoneDays;
151
+ if (!candidateRecentlyMoved)
152
+ return false;
153
+ const openSiblings = siblings.filter((sib) => sib.closedAt === null &&
154
+ sib.workflowState !== "Done" &&
155
+ sib.workflowState !== "Canceled");
156
+ return openSiblings.length > 0;
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // scoreIssue
160
+ // ---------------------------------------------------------------------------
161
+ /**
162
+ * Score a single dashboard item. Returns the winning kind for this candidate
163
+ * in precedence order:
164
+ *
165
+ * detectLockStale(item) -> kind: "lock-stale"
166
+ * else detectTreeContinue(item) -> kind: "tree-continue"
167
+ * else -> kind: "issue"
168
+ *
169
+ * `tags[]` carries descriptive signals (e.g. "stale", "high-priority",
170
+ * "blocked") that did NOT win the kind slot but still shape the prose
171
+ * `reason` rendered by `buildReason`.
172
+ *
173
+ * PR ranking is handled separately by `rankDirections` — this function
174
+ * never returns kind "pr".
175
+ */
176
+ export function scoreIssue(item, allItems, config) {
177
+ const tags = [];
178
+ let score = priorityScore(item.priority) + phaseScore(item.workflowState);
179
+ const lockStale = detectLockStale(item, config);
180
+ const treeContinue = detectTreeContinue(item, allItems, config);
181
+ // Stale boost (non-lock states only)
182
+ const isStale = !isLockState(item.workflowState) &&
183
+ ageHours(item.updatedAt, config.now) >= config.stuckThresholdHours;
184
+ if (isStale) {
185
+ score += STALE_BOOST;
186
+ tags.push("stale");
187
+ }
188
+ if (lockStale) {
189
+ score += LOCK_STALE_BOOST;
190
+ tags.push("stalled");
191
+ }
192
+ if (treeContinue) {
193
+ score += TREE_CONTINUE_BOOST;
194
+ tags.push("tree");
195
+ }
196
+ // Descriptive-only tags
197
+ if (item.priority === "P0" || item.priority === "P1") {
198
+ tags.push("high-priority");
199
+ }
200
+ if (hasOpenBlockers(item)) {
201
+ tags.push("blocked");
202
+ }
203
+ // Pick winning kind in precedence order
204
+ let kind;
205
+ if (lockStale) {
206
+ kind = "lock-stale";
207
+ }
208
+ else if (treeContinue) {
209
+ kind = "tree-continue";
210
+ }
211
+ else {
212
+ kind = "issue";
213
+ }
214
+ return { score, kind, tags };
215
+ }
216
+ function parseIssueNumberFromHeadRef(headRefName) {
217
+ // Match "GH-42" or "GH-0042" anywhere in the ref.
218
+ const m = headRefName.match(/GH-0*(\d+)/);
219
+ if (!m)
220
+ return null;
221
+ const n = Number(m[1]);
222
+ return Number.isFinite(n) ? n : null;
223
+ }
224
+ function scorePR(pr, config) {
225
+ // Drafts are excluded from ranking entirely.
226
+ if (pr.isDraft)
227
+ return null;
228
+ // APPROVED PRs are not surfaced — they are waiting on merge, not user
229
+ // attention. CHANGES_REQUESTED also skipped (author needs to push fixes,
230
+ // not the briefing user).
231
+ if (pr.reviewDecision === "APPROVED")
232
+ return null;
233
+ const tags = [];
234
+ let score = 0;
235
+ if (pr.reviewDecision === "REVIEW_REQUIRED") {
236
+ score += PR_REVIEW_REQUIRED_BOOST;
237
+ tags.push("needs-review");
238
+ }
239
+ // Older PRs rank slightly higher (more negative) than fresher ones.
240
+ // 1 point per hour beyond prStaleHours, capped at -50 to keep them from
241
+ // dominating REVIEW_REQUIRED items already at -200.
242
+ if (pr.ageHours > config.prStaleHours) {
243
+ const extra = Math.min(50, pr.ageHours - config.prStaleHours);
244
+ score -= extra;
245
+ tags.push("stale");
246
+ }
247
+ // PRs that did not pick up a boost (no review required, fresh) should
248
+ // not surface — they are work-in-progress noise.
249
+ if (score === 0)
250
+ return null;
251
+ const linkedIssueNumber = parseIssueNumberFromHeadRef(pr.headRefName);
252
+ return {
253
+ pr,
254
+ score,
255
+ reason: "", // filled in by buildReason at finalization time
256
+ tags,
257
+ linkedIssueNumber,
258
+ };
259
+ }
260
+ // ---------------------------------------------------------------------------
261
+ // buildReason
262
+ // ---------------------------------------------------------------------------
263
+ /**
264
+ * Render a single-sentence prose reason for a direction. Distinct shapes
265
+ * per kind so the output reads as natural English rather than
266
+ * template-y. No trailing period — the consumer wraps the sentence into
267
+ * a paragraph at presentation time.
268
+ */
269
+ export function buildReason(kind, issue, pr, tags, config, linkedIssueNumber = null) {
270
+ if (kind === "pr" && pr) {
271
+ const days = Math.max(1, Math.floor(pr.ageHours / 24));
272
+ const dayLabel = days === 1 ? "day" : "days";
273
+ if (pr.reviewDecision === "REVIEW_REQUIRED") {
274
+ const linkClause = linkedIssueNumber !== null
275
+ ? ` (issue #${linkedIssueNumber})`
276
+ : "";
277
+ return `PR #${pr.number} needs review${linkClause} — open ${days} ${dayLabel}`;
278
+ }
279
+ return `PR #${pr.number} has been open ${days} ${dayLabel} without movement`;
280
+ }
281
+ if (!issue)
282
+ return "Unknown direction";
283
+ if (kind === "lock-stale") {
284
+ const hours = Math.round(ageHours(issue.updatedAt, config.now));
285
+ const days = Math.max(1, Math.floor(hours / 24));
286
+ const dayLabel = days === 1 ? "day" : "days";
287
+ return `Stuck in ${issue.workflowState} for ${days} ${dayLabel} — may be blocked`;
288
+ }
289
+ if (kind === "tree-continue") {
290
+ return `#${issue.number} is part of an active tree — keep it moving before starting something new`;
291
+ }
292
+ // kind === "issue"
293
+ const phase = issue.workflowState ?? "Backlog";
294
+ if (tags.includes("stale")) {
295
+ const hours = Math.round(ageHours(issue.updatedAt, config.now));
296
+ const days = Math.max(1, Math.floor(hours / 24));
297
+ const dayLabel = days === 1 ? "day" : "days";
298
+ return `${phase} for ${days} ${dayLabel} — likely the most unblocking thing`;
299
+ }
300
+ if (issue.priority === "P0") {
301
+ return `P0 in ${phase} — top of the queue`;
302
+ }
303
+ if (issue.priority === "P1") {
304
+ return `P1 in ${phase} — worth a look`;
305
+ }
306
+ return `${phase} — next in line`;
307
+ }
308
+ function isCandidatePhase(state) {
309
+ if (state === null)
310
+ return false;
311
+ return ACTIONABLE_PHASES.has(state);
312
+ }
313
+ function toDirectionIssue(item) {
314
+ return {
315
+ number: item.number,
316
+ title: item.title,
317
+ workflowState: item.workflowState,
318
+ priority: item.priority,
319
+ estimate: item.estimate,
320
+ };
321
+ }
322
+ function toDirectionPR(pr) {
323
+ return {
324
+ number: pr.number,
325
+ title: pr.title,
326
+ url: pr.url,
327
+ ageHours: pr.ageHours,
328
+ reviewDecision: pr.reviewDecision,
329
+ };
330
+ }
331
+ /**
332
+ * Main entry point. Filters, scores, sorts, and slices a candidate set
333
+ * into up to `config.limit` deterministically-ranked directions.
334
+ *
335
+ * Determinism contract: same input + same `config.now` -> byte-identical
336
+ * output across calls. Achieved by:
337
+ * - never reading `Date.now()` inside the lib
338
+ * - using stable secondary sort keys (issue number / PR number)
339
+ * - never iterating Sets/Maps for ordered work
340
+ */
341
+ export function rankDirections(items, openPRs, config) {
342
+ // 1. Score all items first so we know which ones lock-stale (those go in
343
+ // the candidate set even if their phase is not actionable).
344
+ const scored = [];
345
+ for (const item of items) {
346
+ const isLockStale = detectLockStale(item, config);
347
+ const passesPhaseFilter = isCandidatePhase(item.workflowState) || isLockStale;
348
+ if (!passesPhaseFilter)
349
+ continue;
350
+ const { score, kind, tags } = scoreIssue(item, items, config);
351
+ scored.push({ item, score, kind, tags });
352
+ }
353
+ // 2. Drop blocked items unless that would empty the candidate set.
354
+ const unblocked = scored.filter((s) => !hasOpenBlockers(s.item));
355
+ let candidates;
356
+ if (unblocked.length > 0) {
357
+ candidates = unblocked;
358
+ }
359
+ else if (scored.length > 0) {
360
+ // Surface the blocked candidates so the briefing isn't silent. Tags
361
+ // already include "blocked" via scoreIssue.
362
+ candidates = scored;
363
+ }
364
+ else {
365
+ candidates = [];
366
+ }
367
+ // 3. Score PRs.
368
+ const prScored = [];
369
+ for (const pr of openPRs) {
370
+ const s = scorePR(pr, config);
371
+ if (s)
372
+ prScored.push(s);
373
+ }
374
+ const merged = [];
375
+ for (const c of candidates)
376
+ merged.push({ kind: "issueRow", payload: c });
377
+ for (const p of prScored)
378
+ merged.push({ kind: "prRow", payload: p });
379
+ merged.sort((a, b) => {
380
+ const scoreA = a.kind === "issueRow" ? a.payload.score : a.payload.score;
381
+ const scoreB = b.kind === "issueRow" ? b.payload.score : b.payload.score;
382
+ if (scoreA !== scoreB)
383
+ return scoreA - scoreB;
384
+ // Secondary: PRs before issues at the same score (PRs are usually
385
+ // higher-urgency action items: "merge or reply" beats "consider").
386
+ if (a.kind !== b.kind)
387
+ return a.kind === "prRow" ? -1 : 1;
388
+ if (a.kind === "issueRow" && b.kind === "issueRow") {
389
+ return a.payload.item.number - b.payload.item.number;
390
+ }
391
+ if (a.kind === "prRow" && b.kind === "prRow") {
392
+ return a.payload.pr.number - b.payload.pr.number;
393
+ }
394
+ return 0;
395
+ });
396
+ // 5. Tree-continue promotion: if a tree-continue is anywhere in the
397
+ // top 5 of the merged list but not in slot 1, promote it to slot 2.
398
+ if (merged.length >= 2) {
399
+ const slot1IsTreeContinue = merged[0].kind === "issueRow" &&
400
+ merged[0].payload.kind === "tree-continue";
401
+ if (!slot1IsTreeContinue) {
402
+ const limitToScan = Math.min(5, merged.length);
403
+ let treeIdx = -1;
404
+ for (let i = 1; i < limitToScan; i++) {
405
+ const e = merged[i];
406
+ if (e.kind === "issueRow" && e.payload.kind === "tree-continue") {
407
+ treeIdx = i;
408
+ break;
409
+ }
410
+ }
411
+ if (treeIdx > 1) {
412
+ const [moved] = merged.splice(treeIdx, 1);
413
+ merged.splice(1, 0, moved);
414
+ }
415
+ }
416
+ }
417
+ // 6. Slice to limit and assign rank.
418
+ const sliced = merged.slice(0, Math.max(0, config.limit));
419
+ const directions = sliced.map((entry, idx) => {
420
+ const rank = idx + 1;
421
+ if (entry.kind === "issueRow") {
422
+ const c = entry.payload;
423
+ const reason = buildReason(c.kind, c.item, null, c.tags, config, null);
424
+ return {
425
+ rank,
426
+ kind: c.kind,
427
+ issue: toDirectionIssue(c.item),
428
+ pr: null,
429
+ reason,
430
+ tags: c.tags,
431
+ score: c.score,
432
+ };
433
+ }
434
+ // PR row
435
+ const p = entry.payload;
436
+ const reason = buildReason("pr", null, p.pr, p.tags, config, p.linkedIssueNumber);
437
+ return {
438
+ rank,
439
+ kind: "pr",
440
+ issue: null,
441
+ pr: toDirectionPR(p.pr),
442
+ reason,
443
+ tags: p.tags,
444
+ score: p.score,
445
+ };
446
+ });
447
+ return directions;
448
+ }
449
+ //# sourceMappingURL=directions.js.map
@@ -98,6 +98,8 @@ export function toDashboardItems(raw, projectNumber, projectTitle) {
98
98
  number: n.number,
99
99
  workflowState: n.state === "CLOSED" ? "Done" : null,
100
100
  })) ?? [],
101
+ parentNumber: r.content.trackedInIssues?.nodes?.[0]?.number ?? null,
102
+ parentState: r.content.trackedInIssues?.nodes?.[0]?.state ?? null,
101
103
  ...(projectNumber !== undefined ? { projectNumber } : {}),
102
104
  ...(projectTitle !== undefined ? { projectTitle } : {}),
103
105
  ...(r.content.repository ? { repository: r.content.repository.nameWithOwner } : {}),
@@ -135,6 +137,7 @@ export const DASHBOARD_ITEMS_QUERY = `query($projectId: ID!, $cursor: String, $f
135
137
  repository { nameWithOwner name }
136
138
  subIssues { totalCount }
137
139
  trackedIssues(first: 10) { nodes { number state } }
140
+ trackedInIssues(first: 3) { nodes { number state closedAt } }
138
141
  }
139
142
  ... on PullRequest {
140
143
  __typename
@@ -0,0 +1,159 @@
1
+ /**
2
+ * MCP tool wrapping the pure ranker at `lib/directions.ts`.
3
+ *
4
+ * Exposes `ralph_hero__hello_directions`: a single-tool, fixed-shape,
5
+ * deterministic surface that returns up to N ranked "directions" for the
6
+ * `hello` skill's session briefing. The skill is responsible for fetching
7
+ * open PRs (via `gh pr list`) and passing them in as a parameter — the
8
+ * MCP server does not itself open an Octokit-style PR API surface.
9
+ *
10
+ * Behaviour:
11
+ * 1. Resolve owner + project numbers from args or client config.
12
+ * 2. For each project, ensure the field cache is populated, then
13
+ * paginate `DASHBOARD_ITEMS_QUERY` to gather up to 500 items.
14
+ * 3. Convert raw items to `DashboardItem[]` via `toDashboardItems`.
15
+ * 4. Build a `RankConfig` from the args + defaults (with injected `now`).
16
+ * 5. Compute each PR's `ageHours` at the boundary and call
17
+ * `rankDirections(allItems, enrichedPRs, config)`.
18
+ * 6. Return `{ directions, fetchedAt, totalCandidates }`.
19
+ *
20
+ * Determinism: `fetchedAt` is the only time-varying field. Two consecutive
21
+ * calls on the same board state produce byte-identical `directions[]`
22
+ * (after stripping `fetchedAt`).
23
+ */
24
+ import { z } from "zod";
25
+ import { ensureFieldCache } from "../lib/helpers.js";
26
+ import { paginateConnection } from "../lib/pagination.js";
27
+ import { DASHBOARD_ITEMS_QUERY, toDashboardItems, } from "./dashboard-tools.js";
28
+ import { rankDirections, DEFAULT_RANK_CONFIG, } from "../lib/directions.js";
29
+ import { toolSuccess, toolError, resolveProjectOwner, resolveProjectNumbers, } from "../types.js";
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+ const HOUR_MS = 60 * 60 * 1000;
34
+ // ---------------------------------------------------------------------------
35
+ // Register
36
+ // ---------------------------------------------------------------------------
37
+ export function registerDirectionsTools(server, client, fieldCache) {
38
+ server.tool("ralph_hero__hello_directions", "Compute up to N deterministic 'directions' for the hello skill's session briefing. Single tool call returns a fixed-shape JSON payload with ranked issue/PR action items. Open PRs must be passed in as a parameter (the MCP server does not fetch them itself).", {
39
+ owner: z
40
+ .string()
41
+ .optional()
42
+ .describe("GitHub owner. Defaults to RALPH_GH_OWNER env var."),
43
+ projectNumbers: z
44
+ .array(z.coerce.number())
45
+ .optional()
46
+ .describe("Project numbers to include. Defaults to RALPH_GH_PROJECT_NUMBERS or single configured project."),
47
+ limit: z
48
+ .number()
49
+ .int()
50
+ .nonnegative()
51
+ .optional()
52
+ .default(3)
53
+ .describe("Max directions to return (default: 3)."),
54
+ stuckThresholdHours: z
55
+ .number()
56
+ .nonnegative()
57
+ .optional()
58
+ .default(48)
59
+ .describe("Hours before a non-lock issue is considered stale (default: 48)."),
60
+ lockStaleHours: z
61
+ .number()
62
+ .nonnegative()
63
+ .optional()
64
+ .default(24)
65
+ .describe("Hours before a lock-state issue is considered stalled (default: 24)."),
66
+ treeRecentDoneDays: z
67
+ .number()
68
+ .nonnegative()
69
+ .optional()
70
+ .default(7)
71
+ .describe("Days within which a sibling Done event still pulls a tree forward (default: 7)."),
72
+ prStaleHours: z
73
+ .number()
74
+ .nonnegative()
75
+ .optional()
76
+ .default(24)
77
+ .describe("Hours before an open PR is considered stale (default: 24)."),
78
+ openPRs: z
79
+ .array(z.object({
80
+ number: z.number(),
81
+ title: z.string(),
82
+ url: z.string(),
83
+ isDraft: z.boolean(),
84
+ reviewDecision: z
85
+ .string()
86
+ .nullable()
87
+ .describe("REVIEW_REQUIRED | APPROVED | CHANGES_REQUESTED | null"),
88
+ headRefName: z.string(),
89
+ createdAt: z
90
+ .string()
91
+ .describe("ISO timestamp from gh pr list"),
92
+ }))
93
+ .optional()
94
+ .default([])
95
+ .describe("Open PRs gathered by the caller (e.g. via `gh pr list`). Drafts and APPROVED PRs are filtered internally."),
96
+ }, async (args) => {
97
+ try {
98
+ const owner = args.owner || resolveProjectOwner(client.config);
99
+ if (!owner) {
100
+ return toolError("owner is required");
101
+ }
102
+ const projectNumbers = args.projectNumbers ?? resolveProjectNumbers(client.config);
103
+ if (projectNumbers.length === 0) {
104
+ return toolError("No project numbers configured. Set RALPH_GH_PROJECT_NUMBER or RALPH_GH_PROJECT_NUMBERS.");
105
+ }
106
+ // Inject `now` at the boundary so the lib remains deterministic.
107
+ const now = new Date();
108
+ const allItems = [];
109
+ for (const pn of projectNumbers) {
110
+ await ensureFieldCache(client, fieldCache, owner, pn);
111
+ const projectId = fieldCache.getProjectId(pn);
112
+ if (!projectId) {
113
+ // Defensive: ensureFieldCache succeeded but no projectId.
114
+ // Skip this project rather than blowing up the whole call.
115
+ continue;
116
+ }
117
+ const result = await paginateConnection((q, v) => client.projectQuery(q, v), DASHBOARD_ITEMS_QUERY, { projectId, first: 100 }, "node.items", { maxItems: 500 });
118
+ allItems.push(...toDashboardItems(result.nodes, pn));
119
+ }
120
+ // Build the RankConfig from args + defaults + injected `now`.
121
+ const config = {
122
+ limit: args.limit ?? DEFAULT_RANK_CONFIG.limit,
123
+ stuckThresholdHours: args.stuckThresholdHours ?? DEFAULT_RANK_CONFIG.stuckThresholdHours,
124
+ lockStaleHours: args.lockStaleHours ?? DEFAULT_RANK_CONFIG.lockStaleHours,
125
+ treeRecentDoneDays: args.treeRecentDoneDays ?? DEFAULT_RANK_CONFIG.treeRecentDoneDays,
126
+ prStaleHours: args.prStaleHours ?? DEFAULT_RANK_CONFIG.prStaleHours,
127
+ now,
128
+ };
129
+ // Compute PR ageHours at the boundary so the lib never reads the wall clock.
130
+ const enrichedPRs = (args.openPRs ?? []).map((pr) => {
131
+ const t = new Date(pr.createdAt).getTime();
132
+ const ageHours = Number.isNaN(t)
133
+ ? 0
134
+ : Math.max(0, (now.getTime() - t) / HOUR_MS);
135
+ return {
136
+ number: pr.number,
137
+ title: pr.title,
138
+ url: pr.url,
139
+ isDraft: pr.isDraft,
140
+ reviewDecision: pr.reviewDecision,
141
+ headRefName: pr.headRefName,
142
+ createdAt: pr.createdAt,
143
+ ageHours,
144
+ };
145
+ });
146
+ const directions = rankDirections(allItems, enrichedPRs, config);
147
+ return toolSuccess({
148
+ directions,
149
+ fetchedAt: now.toISOString(),
150
+ totalCandidates: allItems.length,
151
+ });
152
+ }
153
+ catch (error) {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ return toolError(`Failed to compute hello directions: ${message}`);
156
+ }
157
+ });
158
+ }
159
+ //# sourceMappingURL=directions-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.73",
3
+ "version": "2.5.75",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",