ralph-hero-mcp-server 2.5.109 → 2.5.111
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/lib/directions.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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);
|
|
@@ -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) => {
|