ralph-hero-mcp-server 2.5.85 → 2.5.87

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.
@@ -182,6 +182,52 @@ export function detectTreeContinue(item, allItems, config) {
182
182
  sib.workflowState !== "Canceled");
183
183
  return openSiblings.length > 0;
184
184
  }
185
+ /**
186
+ * Compute a structured note describing which `detectTreeContinue` branch
187
+ * fired and the supporting evidence. Returns `null` if the candidate is
188
+ * not actually a tree-continue match (caller should not have called this).
189
+ *
190
+ * Two shapes (matching `detectTreeContinue`'s rule order):
191
+ * - rule (a): "sibling #NNN closed N day(s) ago"
192
+ * - rule (b): "candidate moved N day(s) ago; M open sibling(s)"
193
+ */
194
+ function buildParentChainNote(item, allItems, config) {
195
+ const parent = item.parentNumber ?? null;
196
+ if (parent === null || parent === undefined)
197
+ return null;
198
+ const siblings = allItems.filter((other) => other.number !== item.number && other.parentNumber === parent);
199
+ // Rule (a): find the most-recently-closed sibling within the window
200
+ // (deterministic: pick the one with the smallest sibling number among
201
+ // those tied on closedAt, matching the natural source order).
202
+ let closestSibling = null;
203
+ for (const sib of siblings) {
204
+ if (!sib.closedAt)
205
+ continue;
206
+ const days = ageDays(sib.closedAt, config.now);
207
+ if (days > config.treeRecentDoneDays)
208
+ continue;
209
+ const dayInt = Math.max(1, Math.floor(days));
210
+ if (closestSibling === null ||
211
+ dayInt < closestSibling.days ||
212
+ (dayInt === closestSibling.days && sib.number < closestSibling.number)) {
213
+ closestSibling = { number: sib.number, days: dayInt };
214
+ }
215
+ }
216
+ if (closestSibling !== null) {
217
+ const dayLabel = closestSibling.days === 1 ? "day" : "days";
218
+ return `sibling #${closestSibling.number} closed ${closestSibling.days} ${dayLabel} ago`;
219
+ }
220
+ // Rule (b): candidate-moved branch
221
+ const candidateDays = Math.max(1, Math.floor(ageDays(item.updatedAt, config.now)));
222
+ const openSiblings = siblings.filter((sib) => sib.closedAt === null &&
223
+ sib.workflowState !== "Done" &&
224
+ sib.workflowState !== "Canceled");
225
+ if (openSiblings.length === 0)
226
+ return null;
227
+ const dayLabel = candidateDays === 1 ? "day" : "days";
228
+ const sibLabel = openSiblings.length === 1 ? "sibling" : "siblings";
229
+ return `candidate moved ${candidateDays} ${dayLabel} ago; ${openSiblings.length} open ${sibLabel}`;
230
+ }
185
231
  // ---------------------------------------------------------------------------
186
232
  // scoreIssue
187
233
  // ---------------------------------------------------------------------------
@@ -194,8 +240,10 @@ export function detectTreeContinue(item, allItems, config) {
194
240
  * else -> kind: "issue"
195
241
  *
196
242
  * `tags[]` carries descriptive signals (e.g. "stale", "high-priority",
197
- * "blocked") that did NOT win the kind slot but still shape the prose
198
- * `reason` rendered by `buildReason`.
243
+ * "blocked"). `signals` carries the structured per-direction explanation
244
+ * (staleDays, parentChainNote, estimateWeight, etc.) that skills use to
245
+ * synthesize prose without rendering the legacy `reason` template
246
+ * verbatim.
199
247
  *
200
248
  * PR ranking is handled separately by `rankDirections` — this function
201
249
  * never returns kind "pr".
@@ -205,7 +253,8 @@ export function scoreIssue(item, allItems, config) {
205
253
  let score = priorityScore(item.priority) + phaseScore(item.workflowState);
206
254
  // Audience-aware estimate penalty (no-op for "human"; pushes XL items
207
255
  // down for "agent" so autonomous loops favor XS/S work).
208
- score += audiencePenalty(item, config.audience);
256
+ const estPenalty = audiencePenalty(item, config.audience);
257
+ score += estPenalty;
209
258
  const lockStale = detectLockStale(item, config);
210
259
  const treeContinue = detectTreeContinue(item, allItems, config);
211
260
  // Stale boost (non-lock states only)
@@ -241,7 +290,33 @@ export function scoreIssue(item, allItems, config) {
241
290
  else {
242
291
  kind = "issue";
243
292
  }
244
- return { score, kind, tags };
293
+ // Compute structured signals for skills to synthesize prose. tiedAtScore
294
+ // is added later by rankDirections after the post-sort pass.
295
+ const signals = { tags: [...tags] };
296
+ if (kind === "lock-stale") {
297
+ const days = Math.max(1, Math.floor(ageHours(item.updatedAt, config.now) / 24));
298
+ signals.staleDays = days;
299
+ signals.staleThresholdDays = config.lockStaleHours / 24;
300
+ }
301
+ else {
302
+ // For non-lock items, surface the threshold informationally and
303
+ // populate staleDays only when the stale tag fired.
304
+ signals.staleThresholdDays = config.stuckThresholdHours / 24;
305
+ if (isStale) {
306
+ const days = Math.max(1, Math.floor(ageHours(item.updatedAt, config.now) / 24));
307
+ signals.staleDays = days;
308
+ }
309
+ }
310
+ if (estPenalty > 0) {
311
+ signals.estimateWeight = estPenalty;
312
+ }
313
+ if (kind === "tree-continue") {
314
+ const note = buildParentChainNote(item, allItems, config);
315
+ if (note !== null) {
316
+ signals.parentChainNote = note;
317
+ }
318
+ }
319
+ return { score, kind, tags, signals };
245
320
  }
246
321
  function parseIssueNumberFromHeadRef(headRefName) {
247
322
  // Match "GH-42" or "GH-0042" anywhere in the ref.
@@ -279,12 +354,21 @@ function scorePR(pr, config) {
279
354
  if (score === 0)
280
355
  return null;
281
356
  const linkedIssueNumber = parseIssueNumberFromHeadRef(pr.headRefName);
357
+ const signals = {
358
+ tags: [...tags],
359
+ prAgeDays: Math.max(1, Math.floor(pr.ageHours / 24)),
360
+ prReviewDecision: pr.reviewDecision,
361
+ };
362
+ if (linkedIssueNumber !== null) {
363
+ signals.linkedIssueNumber = linkedIssueNumber;
364
+ }
282
365
  return {
283
366
  pr,
284
367
  score,
285
368
  reason: "", // filled in by buildReason at finalization time
286
369
  tags,
287
370
  linkedIssueNumber,
371
+ signals,
288
372
  };
289
373
  }
290
374
  // ---------------------------------------------------------------------------
@@ -295,8 +379,12 @@ function scorePR(pr, config) {
295
379
  * per kind so the output reads as natural English rather than
296
380
  * template-y. No trailing period — the consumer wraps the sentence into
297
381
  * a paragraph at presentation time.
382
+ *
383
+ * @deprecated Reason strings are derived from signals for back-compat.
384
+ * Skills should synthesize prose from signals directly. Removed in 2.7.0.
298
385
  */
299
- export function buildReason(kind, issue, pr, tags, config, linkedIssueNumber = null) {
386
+ export function buildReason(kind, issue, pr, signals, config, linkedIssueNumber = null) {
387
+ const tags = signals.tags;
300
388
  if (kind === "pr" && pr) {
301
389
  const days = Math.max(1, Math.floor(pr.ageHours / 24));
302
390
  const dayLabel = days === 1 ? "day" : "days";
@@ -390,8 +478,8 @@ export function rankDirections(items, openPRs, config) {
390
478
  const passesPhaseFilter = isCandidatePhase(item.workflowState) || isLockStale;
391
479
  if (!passesPhaseFilter)
392
480
  continue;
393
- const { score, kind, tags } = scoreIssue(item, items, config);
394
- scored.push({ item, score, kind, tags });
481
+ const { score, kind, tags, signals } = scoreIssue(item, items, config);
482
+ scored.push({ item, score, kind, tags, signals });
395
483
  }
396
484
  // 2. Drop blocked items unless that would empty the candidate set.
397
485
  const unblocked = scored.filter((s) => !hasOpenBlockers(s.item));
@@ -459,17 +547,32 @@ export function rankDirections(items, openPRs, config) {
459
547
  }
460
548
  // 6. Slice to limit and assign rank.
461
549
  const sliced = merged.slice(0, Math.max(0, config.limit));
550
+ // 6a. Compute tied-at-top-score count from the sliced (final) list so the
551
+ // tie reflects what the user actually sees. Stamped onto each entry's
552
+ // signals only when the count is > 1; omitted otherwise.
553
+ const tiedCount = sliced.length === 0
554
+ ? 0
555
+ : sliced.filter((entry) => {
556
+ const s = entry.kind === "issueRow" ? entry.payload.score : entry.payload.score;
557
+ const top = sliced[0].kind === "issueRow" ? sliced[0].payload.score : sliced[0].payload.score;
558
+ return s === top;
559
+ }).length;
462
560
  const directions = sliced.map((entry, idx) => {
463
561
  const rank = idx + 1;
464
562
  if (entry.kind === "issueRow") {
465
563
  const c = entry.payload;
466
- const reason = buildReason(c.kind, c.item, null, c.tags, config, null);
564
+ const signals = { ...c.signals };
565
+ if (tiedCount > 1 && c.score === sliced[0].payload.score) {
566
+ signals.tiedAtScore = tiedCount;
567
+ }
568
+ const reason = buildReason(c.kind, c.item, null, signals, config, null);
467
569
  return {
468
570
  rank,
469
571
  recommended: false,
470
572
  kind: c.kind,
471
573
  issue: toDirectionIssue(c.item),
472
574
  pr: null,
575
+ signals,
473
576
  reason,
474
577
  tags: c.tags,
475
578
  score: c.score,
@@ -477,13 +580,18 @@ export function rankDirections(items, openPRs, config) {
477
580
  }
478
581
  // PR row
479
582
  const p = entry.payload;
480
- const reason = buildReason("pr", null, p.pr, p.tags, config, p.linkedIssueNumber);
583
+ const signals = { ...p.signals };
584
+ if (tiedCount > 1 && p.score === sliced[0].payload.score) {
585
+ signals.tiedAtScore = tiedCount;
586
+ }
587
+ const reason = buildReason("pr", null, p.pr, signals, config, p.linkedIssueNumber);
481
588
  return {
482
589
  rank,
483
590
  recommended: false,
484
591
  kind: "pr",
485
592
  issue: null,
486
593
  pr: toDirectionPR(p.pr),
594
+ signals,
487
595
  reason,
488
596
  tags: p.tags,
489
597
  score: p.score,
@@ -126,7 +126,7 @@ export function makeRunDirections(client, fieldCache) {
126
126
  // ---------------------------------------------------------------------------
127
127
  export function registerDirectionsTools(server, client, fieldCache) {
128
128
  const runDirections = makeRunDirections(client, fieldCache);
129
- server.tool("ralph_hero__hello_directions", "[DEPRECATED — use ralph_hero__next_actions instead. Removed in 2.7.0.] Compute up to N deterministic 'directions' for the hello skill's session briefing.", {
129
+ server.tool("ralph_hero__hello_directions", "[DEPRECATED — use ralph_hero__next_actions instead. Removed in 2.7.0.] Compute up to N deterministic 'directions' for the hello skill's session briefing. Each direction includes a structured signals object (staleDays, staleThresholdDays, tiedAtScore, estimateWeight, parentChainNote) for skills to synthesize prose. The legacy 'reason' string is @deprecated and removed in 2.7.0.", {
130
130
  owner: z
131
131
  .string()
132
132
  .optional()
@@ -174,7 +174,7 @@ export function registerDirectionsTools(server, client, fieldCache) {
174
174
  }, async (args) => {
175
175
  return await runDirections({ ...args, audience: "human" });
176
176
  });
177
- server.tool("ralph_hero__next_actions", "Compute up to N deterministic 'directions' (next actions) with one flagged `recommended: true`. Used by the /hello skill picker (interactive) and by headless orchestrators (auto-select recommended). Open PRs must be passed in as a parameter.", {
177
+ server.tool("ralph_hero__next_actions", "Compute up to N deterministic 'directions' (next actions) with one flagged `recommended: true`. Used by the /hello skill picker (interactive) and by headless orchestrators (auto-select recommended). Open PRs must be passed in as a parameter. Each direction includes a structured signals object (staleDays, staleThresholdDays, tiedAtScore, estimateWeight, parentChainNote) for skills to synthesize prose. The legacy 'reason' string is @deprecated and removed in 2.7.0.", {
178
178
  owner: z
179
179
  .string()
180
180
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.85",
3
+ "version": "2.5.87",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",