ralph-hero-mcp-server 2.5.110 → 2.5.112

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.
@@ -61,6 +61,13 @@ const STALE_BOOST = -50;
61
61
  const LOCK_STALE_BOOST = -100;
62
62
  const TREE_CONTINUE_BOOST = -75;
63
63
  const PR_REVIEW_REQUIRED_BOOST = -200;
64
+ /**
65
+ * Boost applied to a Human Needed issue carrying a fresh `## Unblock Request`
66
+ * comment. Must be more negative than `LOCK_STALE_BOOST` so the unblock
67
+ * direction outranks any lock-stale entry — these issues are explicitly
68
+ * waiting for the human's attention.
69
+ */
70
+ const HUMAN_NEEDED_UNBLOCK_BOOST = -150;
64
71
  /**
65
72
  * Per-estimate penalty applied when audience === "agent". Larger items
66
73
  * cost more (positive score), pushing them down the ranking so agent
@@ -235,7 +242,8 @@ function buildParentChainNote(item, allItems, config) {
235
242
  * Score a single dashboard item. Returns the winning kind for this candidate
236
243
  * in precedence order:
237
244
  *
238
- * detectLockStale(item) -> kind: "lock-stale"
245
+ * has unblock signal (Human Needed) -> kind: "human-needed-unblock"
246
+ * else detectLockStale(item) -> kind: "lock-stale"
239
247
  * else detectTreeContinue(item) -> kind: "tree-continue"
240
248
  * else -> kind: "issue"
241
249
  *
@@ -255,6 +263,30 @@ export function scoreIssue(item, allItems, config) {
255
263
  // down for "agent" so autonomous loops favor XS/S work).
256
264
  const estPenalty = audiencePenalty(item, config.audience);
257
265
  score += estPenalty;
266
+ // Human Needed + fresh `## Unblock Request` comment takes precedence over
267
+ // every other detection. The signal is computed at the tool boundary.
268
+ const unblockSignal = item.workflowState === "Human Needed"
269
+ ? config.unblockSignals?.[item.number]
270
+ : undefined;
271
+ if (unblockSignal !== undefined) {
272
+ score += HUMAN_NEEDED_UNBLOCK_BOOST;
273
+ tags.push("unblock-requested");
274
+ if (item.priority === "P0" || item.priority === "P1") {
275
+ tags.push("high-priority");
276
+ }
277
+ if (hasOpenBlockers(item)) {
278
+ tags.push("blocked");
279
+ }
280
+ const signals = {
281
+ tags: [...tags],
282
+ unblockRequestAgeDays: unblockSignal.unblockRequestAgeDays,
283
+ questionCount: unblockSignal.questionCount,
284
+ };
285
+ if (estPenalty > 0) {
286
+ signals.estimateWeight = estPenalty;
287
+ }
288
+ return { score, kind: "human-needed-unblock", tags, signals };
289
+ }
258
290
  const lockStale = detectLockStale(item, config);
259
291
  const treeContinue = detectTreeContinue(item, allItems, config);
260
292
  // Stale boost (non-lock states only)
@@ -398,6 +430,16 @@ export function buildReason(kind, issue, pr, signals, config, linkedIssueNumber
398
430
  }
399
431
  if (!issue)
400
432
  return "Unknown direction";
433
+ if (kind === "human-needed-unblock") {
434
+ const days = signals.unblockRequestAgeDays ?? 0;
435
+ const dayLabel = days === 1 ? "day" : "days";
436
+ const qCount = signals.questionCount ?? 0;
437
+ const qLabel = qCount === 1 ? "question" : "questions";
438
+ if (days === 0) {
439
+ return `Human Needed — ${qCount} unblock ${qLabel} waiting (posted today)`;
440
+ }
441
+ return `Human Needed — ${qCount} unblock ${qLabel} waiting since ${days} ${dayLabel} ago`;
442
+ }
401
443
  if (kind === "lock-stale") {
402
444
  const hours = Math.round(ageHours(issue.updatedAt, config.now));
403
445
  const days = Math.max(1, Math.floor(hours / 24));
@@ -471,11 +513,16 @@ function toDirectionPR(pr) {
471
513
  */
472
514
  export function rankDirections(items, openPRs, config) {
473
515
  // 1. Score all items first so we know which ones lock-stale (those go in
474
- // the candidate set even if their phase is not actionable).
516
+ // the candidate set even if their phase is not actionable). Human
517
+ // Needed items also pass the filter when an unblock signal exists for
518
+ // them — surfacing them as `human-needed-unblock` directions.
475
519
  const scored = [];
476
520
  for (const item of items) {
477
521
  const isLockStale = detectLockStale(item, config);
478
- const passesPhaseFilter = isCandidatePhase(item.workflowState) || isLockStale;
522
+ const hasUnblockSignal = item.workflowState === "Human Needed" &&
523
+ config.unblockSignals !== undefined &&
524
+ config.unblockSignals[item.number] !== undefined;
525
+ const passesPhaseFilter = isCandidatePhase(item.workflowState) || isLockStale || hasUnblockSignal;
479
526
  if (!passesPhaseFilter)
480
527
  continue;
481
528
  const { score, kind, tags, signals } = scoreIssue(item, items, config);
@@ -31,6 +31,9 @@ export const FILTER_PROFILES = {
31
31
  "integrator-merge": {
32
32
  workflowState: "In Review",
33
33
  },
34
+ "analyst-unblock": {
35
+ workflowState: "Human Needed",
36
+ },
34
37
  };
35
38
  /**
36
39
  * Valid profile names, derived from the registry keys.
@@ -47,6 +47,13 @@ const COMMAND_ALLOWED_STATES = {
47
47
  ralph_hero: ["In Review", "Human Needed"],
48
48
  ralph_merge: ["Done", "Human Needed"],
49
49
  ralph_code_review: ["In Review", "Human Needed"],
50
+ ralph_unblock: [
51
+ "Backlog",
52
+ "Research Needed",
53
+ "Ready for Plan",
54
+ "In Progress",
55
+ "Human Needed",
56
+ ],
50
57
  };
51
58
  // --- Helpers ---
52
59
  function isSemanticIntent(state) {
@@ -34,6 +34,121 @@ import { toolSuccess, toolError, resolveProjectOwner, resolveProjectNumbers, } f
34
34
  // Constants
35
35
  // ---------------------------------------------------------------------------
36
36
  const HOUR_MS = 60 * 60 * 1000;
37
+ const DAY_MS = 24 * HOUR_MS;
38
+ /**
39
+ * Fetch the most recent comments on a single issue. Returns an empty list on
40
+ * error (e.g. the issue is private or has been deleted) so the surrounding
41
+ * ranker continues to produce directions.
42
+ */
43
+ async function fetchIssueCommentsForUnblock(client, owner, repo, number) {
44
+ try {
45
+ const data = await client.query(`query($owner: String!, $repo: String!, $number: Int!) {
46
+ repository(owner: $owner, name: $repo) {
47
+ issue(number: $number) {
48
+ comments(last: 20) {
49
+ nodes { body createdAt }
50
+ }
51
+ }
52
+ }
53
+ }`, { owner, repo, number });
54
+ return data.repository?.issue?.comments?.nodes ?? [];
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ /**
61
+ * Parse a list of comments and return the unblock signal for the issue, or
62
+ * `null` if the issue should NOT produce a `human-needed-unblock` direction.
63
+ *
64
+ * Rules (matching plan Phase 4.1):
65
+ * 1. Find the most recent comment whose body starts with `## Unblock Request`.
66
+ * If none exists, return null.
67
+ * 2. Find the most recent comment whose body starts with `## Escalation`.
68
+ * If it exists and is newer than the unblock request, return null
69
+ * (the autonomous skill needs to re-run before the human is asked).
70
+ * 3. Compute `unblockRequestAgeDays` from the unblock request's createdAt.
71
+ * 4. Compute `questionCount` by counting lines matching `^\d+\.\s` in the
72
+ * unblock request body.
73
+ */
74
+ export function extractUnblockSignal(comments, now) {
75
+ // Iterate to find the most recent comment of each header kind. Use
76
+ // string-prefix match because authors may include trailing whitespace
77
+ // or quote a header in a follow-up comment that we want to ignore.
78
+ let latestUnblock = null;
79
+ let latestEscalation = null;
80
+ for (const c of comments) {
81
+ if (c.body.startsWith("## Unblock Request")) {
82
+ if (latestUnblock === null ||
83
+ new Date(c.createdAt).getTime() >
84
+ new Date(latestUnblock.createdAt).getTime()) {
85
+ latestUnblock = c;
86
+ }
87
+ }
88
+ else if (c.body.startsWith("## Escalation")) {
89
+ if (latestEscalation === null ||
90
+ new Date(c.createdAt).getTime() >
91
+ new Date(latestEscalation.createdAt).getTime()) {
92
+ latestEscalation = c;
93
+ }
94
+ }
95
+ }
96
+ if (latestUnblock === null)
97
+ return null;
98
+ // Skip if the most recent escalation is newer than the unblock request.
99
+ if (latestEscalation !== null) {
100
+ const escTs = new Date(latestEscalation.createdAt).getTime();
101
+ const ubTs = new Date(latestUnblock.createdAt).getTime();
102
+ if (escTs > ubTs)
103
+ return null;
104
+ }
105
+ // Age in whole days, rounded down. Floor at 0 (never negative).
106
+ const ts = new Date(latestUnblock.createdAt).getTime();
107
+ const ageMs = Number.isNaN(ts) ? 0 : Math.max(0, now.getTime() - ts);
108
+ const unblockRequestAgeDays = Math.floor(ageMs / DAY_MS);
109
+ // Count question lines: `^\d+\.\s` (e.g. "1. ", "2.\t")
110
+ const questionCount = latestUnblock.body
111
+ .split("\n")
112
+ .filter((line) => /^\d+\.\s/.test(line)).length;
113
+ return { unblockRequestAgeDays, questionCount };
114
+ }
115
+ /**
116
+ * Parse "owner/repo" into its parts. Returns null on malformed input.
117
+ */
118
+ function splitOwnerRepo(nameWithOwner) {
119
+ if (!nameWithOwner)
120
+ return null;
121
+ const parts = nameWithOwner.split("/");
122
+ if (parts.length !== 2)
123
+ return null;
124
+ if (!parts[0] || !parts[1])
125
+ return null;
126
+ return { owner: parts[0], repo: parts[1] };
127
+ }
128
+ /**
129
+ * Build the unblock signal map for all Human Needed candidates in `items`.
130
+ * Skips items that don't carry a parseable repository identifier (we need
131
+ * `owner/repo` to fetch issue comments via the GitHub repo API).
132
+ */
133
+ async function buildUnblockSignalMap(client, items, now) {
134
+ const map = {};
135
+ const candidates = items.filter((item) => item.workflowState === "Human Needed");
136
+ if (candidates.length === 0)
137
+ return map;
138
+ // Fetch comments per candidate. Sequential to keep rate-limit pressure low —
139
+ // the candidate set is typically tiny (0-5).
140
+ for (const item of candidates) {
141
+ const ownerRepo = splitOwnerRepo(item.repository);
142
+ if (!ownerRepo)
143
+ continue;
144
+ const comments = await fetchIssueCommentsForUnblock(client, ownerRepo.owner, ownerRepo.repo, item.number);
145
+ const signal = extractUnblockSignal(comments, now);
146
+ if (signal !== null) {
147
+ map[item.number] = signal;
148
+ }
149
+ }
150
+ return map;
151
+ }
37
152
  // ---------------------------------------------------------------------------
38
153
  // Shared schema fragments
39
154
  // ---------------------------------------------------------------------------
@@ -81,6 +196,10 @@ export function makeRunDirections(client, fieldCache) {
81
196
  const result = await paginateConnection((q, v) => client.projectQuery(q, v), DASHBOARD_ITEMS_QUERY, { projectId, first: 100 }, "node.items", { maxItems: 500 });
82
197
  allItems.push(...toDashboardItems(result.nodes, pn));
83
198
  }
199
+ // Compute unblock signals for any Human Needed candidates so the
200
+ // ranker can surface `human-needed-unblock` directions. Comments are
201
+ // fetched at the boundary; the ranker stays pure.
202
+ const unblockSignals = await buildUnblockSignalMap(client, allItems, now);
84
203
  // Build the RankConfig from args + defaults + injected `now`.
85
204
  const config = {
86
205
  limit: args.limit ?? DEFAULT_RANK_CONFIG.limit,
@@ -90,6 +209,7 @@ export function makeRunDirections(client, fieldCache) {
90
209
  prStaleHours: args.prStaleHours ?? DEFAULT_RANK_CONFIG.prStaleHours,
91
210
  audience: args.audience,
92
211
  now,
212
+ unblockSignals,
93
213
  };
94
214
  // Compute PR ageHours at the boundary so the lib never reads the wall clock.
95
215
  const enrichedPRs = (args.openPRs ?? []).map((pr) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.110",
3
+ "version": "2.5.112",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",