ralph-hero-mcp-server 2.5.73 → 2.5.74

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.
@@ -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
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.74",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",