ralph-hero-mcp-server 2.5.119 → 2.5.122
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 +25 -1
- package/dist/lib/directions.js +1 -1
- package/dist/lib/health.js +180 -0
- package/dist/tools/directions-tools.js +6 -55
- package/dist/tools/issue-tools.js +0 -152
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { FieldOptionCache } from "./lib/cache.js";
|
|
|
16
16
|
import { createDebugLogger, wrapServerToolWithLogging } from "./lib/debug-logger.js";
|
|
17
17
|
import { toolSuccess, resolveProjectOwner } from "./types.js";
|
|
18
18
|
import { resolveRepoFromProject } from "./lib/helpers.js";
|
|
19
|
+
import { detectOrphanRepoIssues } from "./lib/health.js";
|
|
19
20
|
import { registerProjectTools } from "./tools/project-tools.js";
|
|
20
21
|
import { registerIssueTools } from "./tools/issue-tools.js";
|
|
21
22
|
import { registerRelationshipTools } from "./tools/relationship-tools.js";
|
|
@@ -188,7 +189,7 @@ function initGitHubClient(debugLogger) {
|
|
|
188
189
|
*/
|
|
189
190
|
function registerCoreTools(server, client) {
|
|
190
191
|
// Health check tool - comprehensive validation of auth, repo, project, and fields
|
|
191
|
-
server.tool("ralph_hero__health_check", "Validate GitHub API connectivity, token permissions, repo access, project access, and required fields", {}, async () => {
|
|
192
|
+
server.tool("ralph_hero__health_check", "Validate GitHub API connectivity, token permissions, repo access, project access, and required fields. Also surfaces repo-scope mismatch via the `orphanRepoIssues` field when OPEN issues exist in the repo but are NOT on the configured project board (such issues are invisible to discovery tools like next_actions, list_issues, pipeline_dashboard, project_hygiene). The field is omitted entirely when the board contains every OPEN repo issue. Shape when present: `{ count, repoOpen, boardItems, sample: number[], note }` — `sample` is up to 10 orphan issue numbers (ascending) for diagnostic display.", {}, async () => {
|
|
192
193
|
const checks = {};
|
|
193
194
|
// 1. Auth check (repo token)
|
|
194
195
|
try {
|
|
@@ -297,6 +298,26 @@ function registerCoreTools(server, client) {
|
|
|
297
298
|
detail: "RALPH_GH_PROJECT_NUMBER not set",
|
|
298
299
|
};
|
|
299
300
|
}
|
|
301
|
+
// 5. Orphan repo issues check — only runs when both repo and project
|
|
302
|
+
// access succeeded above. Failures here are non-fatal: orphan detection
|
|
303
|
+
// is informational and shouldn't downgrade the overall health status,
|
|
304
|
+
// because the underlying access checks already capture the real failure
|
|
305
|
+
// mode (broken token, missing project, etc.).
|
|
306
|
+
let orphanRepoIssues = null;
|
|
307
|
+
if (checks.repoAccess?.status === "ok" &&
|
|
308
|
+
checks.projectAccess?.status === "ok" &&
|
|
309
|
+
client.config.owner &&
|
|
310
|
+
client.config.repo &&
|
|
311
|
+
projOwner &&
|
|
312
|
+
projNum) {
|
|
313
|
+
try {
|
|
314
|
+
orphanRepoIssues = await detectOrphanRepoIssues(client, client.config.owner, client.config.repo, projOwner, projNum);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
// Non-fatal — log via stderr but don't fail health_check.
|
|
318
|
+
console.error(`[ralph-hero] Orphan repo issues check failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
300
321
|
// Token source detection — re-derive which env vars resolved
|
|
301
322
|
const repoTokenSource = resolveEnv("RALPH_GH_REPO_TOKEN")
|
|
302
323
|
? "RALPH_GH_REPO_TOKEN"
|
|
@@ -327,6 +348,9 @@ function registerCoreTools(server, client) {
|
|
|
327
348
|
? `Repo operations use ${repoTokenSource}, project operations use ${projectTokenSource}`
|
|
328
349
|
: `Both repo and project operations use ${repoTokenSource}`,
|
|
329
350
|
},
|
|
351
|
+
// Omit the field entirely when there are no orphans, per the field's
|
|
352
|
+
// contract (`null` from `detectOrphanRepoIssues` means "clean board").
|
|
353
|
+
...(orphanRepoIssues ? { orphanRepoIssues } : {}),
|
|
330
354
|
});
|
|
331
355
|
});
|
|
332
356
|
}
|
package/dist/lib/directions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure ranker library for the `
|
|
2
|
+
* Pure ranker library for the `ralph_hero__next_actions` MCP tool.
|
|
3
3
|
*
|
|
4
4
|
* Computes up to N deterministic directions for a session-briefing
|
|
5
5
|
* companion. All functions are side-effect free: time is injected via
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health-check helpers — pure functions that probe the GitHub API for
|
|
3
|
+
* configuration drift between the project board and its underlying repo.
|
|
4
|
+
*
|
|
5
|
+
* `detectOrphanRepoIssues` compares the set of OPEN issues in the configured
|
|
6
|
+
* repo against the set of `type=ISSUE` items on the configured project
|
|
7
|
+
* board. Issues present in the repo but not on the board are "orphans" —
|
|
8
|
+
* they exist but are structurally invisible to the discovery tools
|
|
9
|
+
* (`next_actions`, `list_issues`, `pipeline_dashboard`, `project_hygiene`),
|
|
10
|
+
* which all read from the project board.
|
|
11
|
+
*
|
|
12
|
+
* Surfaced via `health_check` so users discover the mismatch without having
|
|
13
|
+
* to grep `gh issue list` against the board contents by hand.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Maximum number of orphan issue numbers to include in the response sample.
|
|
17
|
+
* The sample is for diagnostic display only; the full set is summarized via `count`.
|
|
18
|
+
*/
|
|
19
|
+
export const ORPHAN_SAMPLE_LIMIT = 10;
|
|
20
|
+
/**
|
|
21
|
+
* Page size used when paginating repo issues and project items.
|
|
22
|
+
*
|
|
23
|
+
* GitHub GraphQL caps `first:` at 100. Most projects fit comfortably in a
|
|
24
|
+
* handful of pages — pagination is implemented for correctness, not because
|
|
25
|
+
* every call hits it.
|
|
26
|
+
*/
|
|
27
|
+
const PAGE_SIZE = 100;
|
|
28
|
+
/**
|
|
29
|
+
* Hard cap on pages walked when enumerating repo issues or project items.
|
|
30
|
+
* Defensive: prevents a runaway loop on a misconfigured giant repo.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_PAGES = 20;
|
|
33
|
+
/**
|
|
34
|
+
* Standard explanatory note attached to the orphan-repo-issues warning.
|
|
35
|
+
* Lifted to a constant so tests can assert against a single canonical string.
|
|
36
|
+
*/
|
|
37
|
+
export const ORPHAN_REPO_ISSUES_NOTE = "Issues exist in the repo that are not on the project board. They are " +
|
|
38
|
+
"invisible to discovery tools (next_actions, list_issues, pipeline_dashboard, " +
|
|
39
|
+
"project_hygiene). To make them visible, add them to the project or use " +
|
|
40
|
+
"'gh issue list' directly.";
|
|
41
|
+
/**
|
|
42
|
+
* Fetch all OPEN issue numbers in the repo via paginated GraphQL.
|
|
43
|
+
*
|
|
44
|
+
* Returns `{ totalCount, numbers }`. `totalCount` is taken from the first
|
|
45
|
+
* page (it is constant across pages). `numbers` is the union of all
|
|
46
|
+
* `node.number` values walked.
|
|
47
|
+
*/
|
|
48
|
+
async function fetchRepoOpenIssueNumbers(client, owner, repo) {
|
|
49
|
+
const numbers = new Set();
|
|
50
|
+
let totalCount = 0;
|
|
51
|
+
let cursor = null;
|
|
52
|
+
let pages = 0;
|
|
53
|
+
while (pages < MAX_PAGES) {
|
|
54
|
+
const response = await client.query(`query($owner: String!, $repo: String!, $cursor: String, $first: Int!) {
|
|
55
|
+
repository(owner: $owner, name: $repo) {
|
|
56
|
+
issues(states: OPEN, first: $first, after: $cursor) {
|
|
57
|
+
totalCount
|
|
58
|
+
pageInfo { hasNextPage endCursor }
|
|
59
|
+
nodes { number }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}`, { owner, repo, cursor, first: PAGE_SIZE });
|
|
63
|
+
const page = response.repository?.issues;
|
|
64
|
+
if (!page)
|
|
65
|
+
break;
|
|
66
|
+
if (pages === 0)
|
|
67
|
+
totalCount = page.totalCount;
|
|
68
|
+
for (const node of page.nodes)
|
|
69
|
+
numbers.add(node.number);
|
|
70
|
+
if (!page.pageInfo.hasNextPage)
|
|
71
|
+
break;
|
|
72
|
+
cursor = page.pageInfo.endCursor;
|
|
73
|
+
pages += 1;
|
|
74
|
+
}
|
|
75
|
+
return { totalCount, numbers };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Fetch all `type=ISSUE` item numbers on the project board via paginated GraphQL.
|
|
79
|
+
*
|
|
80
|
+
* The query tries `user(login)` first, then falls back to `organization(login)`
|
|
81
|
+
* to handle both account types. PRs and DraftIssues are skipped — only true
|
|
82
|
+
* `Issue`-typed content with a `number` field is collected.
|
|
83
|
+
*/
|
|
84
|
+
async function fetchBoardIssueNumbers(client, projectOwner, projectNumber) {
|
|
85
|
+
const numbers = new Set();
|
|
86
|
+
let totalCount = 0;
|
|
87
|
+
let cursor = null;
|
|
88
|
+
let pages = 0;
|
|
89
|
+
// The project items query is identical across user/org owner — only the
|
|
90
|
+
// root selector changes. Build a closure that runs the same shape and
|
|
91
|
+
// tries both owner types on the first page.
|
|
92
|
+
const runQuery = async (cur) => {
|
|
93
|
+
for (const ownerType of ["user", "organization"]) {
|
|
94
|
+
try {
|
|
95
|
+
const res = await client.projectQuery(`query($owner: String!, $number: Int!, $cursor: String, $first: Int!) {
|
|
96
|
+
${ownerType}(login: $owner) {
|
|
97
|
+
projectV2(number: $number) {
|
|
98
|
+
items(first: $first, after: $cursor) {
|
|
99
|
+
totalCount
|
|
100
|
+
pageInfo { hasNextPage endCursor }
|
|
101
|
+
nodes {
|
|
102
|
+
type
|
|
103
|
+
content {
|
|
104
|
+
__typename
|
|
105
|
+
... on Issue { number }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}`, { owner: projectOwner, number: projectNumber, cursor: cur, first: PAGE_SIZE });
|
|
112
|
+
const proj = res[ownerType]?.projectV2;
|
|
113
|
+
if (proj)
|
|
114
|
+
return proj;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Try next owner type
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
while (pages < MAX_PAGES) {
|
|
123
|
+
const proj = await runQuery(cursor);
|
|
124
|
+
if (!proj || !proj.items)
|
|
125
|
+
break;
|
|
126
|
+
if (pages === 0)
|
|
127
|
+
totalCount = proj.items.totalCount;
|
|
128
|
+
for (const node of proj.items.nodes) {
|
|
129
|
+
// Only count Issue-typed content. Type field is "ISSUE" but we double-check
|
|
130
|
+
// via __typename because draft-issue items can have type=DRAFT_ISSUE and
|
|
131
|
+
// should never count as repo-issue overlap.
|
|
132
|
+
if (node.type === "ISSUE" &&
|
|
133
|
+
node.content?.__typename === "Issue" &&
|
|
134
|
+
typeof node.content.number === "number") {
|
|
135
|
+
numbers.add(node.content.number);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!proj.items.pageInfo.hasNextPage)
|
|
139
|
+
break;
|
|
140
|
+
cursor = proj.items.pageInfo.endCursor;
|
|
141
|
+
pages += 1;
|
|
142
|
+
}
|
|
143
|
+
return { totalCount, numbers };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Detect repo issues that are absent from the project board.
|
|
147
|
+
*
|
|
148
|
+
* Returns the orphan summary when at least one orphan exists, or `null`
|
|
149
|
+
* when the board contains every OPEN repo issue.
|
|
150
|
+
*
|
|
151
|
+
* Returns `null` (not an empty result) so the `health_check` caller can
|
|
152
|
+
* omit the field entirely on a clean board — keeping the response tight
|
|
153
|
+
* for the common no-orphans case.
|
|
154
|
+
*/
|
|
155
|
+
export async function detectOrphanRepoIssues(client, owner, repo, projectOwner, projectNumber) {
|
|
156
|
+
const [repoSide, boardSide] = await Promise.all([
|
|
157
|
+
fetchRepoOpenIssueNumbers(client, owner, repo),
|
|
158
|
+
fetchBoardIssueNumbers(client, projectOwner, projectNumber),
|
|
159
|
+
]);
|
|
160
|
+
// Orphans = OPEN issues in the repo whose number is NOT in the board set.
|
|
161
|
+
// We compare against the actual numbers walked rather than just totalCount,
|
|
162
|
+
// because a board may contain issues from OTHER repos (multi-repo project)
|
|
163
|
+
// — those would inflate `boardItems` but contribute zero to the overlap.
|
|
164
|
+
const orphanNumbers = [];
|
|
165
|
+
for (const n of repoSide.numbers) {
|
|
166
|
+
if (!boardSide.numbers.has(n))
|
|
167
|
+
orphanNumbers.push(n);
|
|
168
|
+
}
|
|
169
|
+
orphanNumbers.sort((a, b) => a - b);
|
|
170
|
+
if (orphanNumbers.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
return {
|
|
173
|
+
count: orphanNumbers.length,
|
|
174
|
+
repoOpen: repoSide.totalCount,
|
|
175
|
+
boardItems: boardSide.totalCount,
|
|
176
|
+
sample: orphanNumbers.slice(0, ORPHAN_SAMPLE_LIMIT),
|
|
177
|
+
note: ORPHAN_REPO_ISSUES_NOTE,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tools wrapping the pure ranker at `lib/directions.ts`.
|
|
3
3
|
*
|
|
4
|
-
* Exposes
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Both return a fixed-shape JSON payload with up to N ranked "directions"
|
|
9
|
-
* for the `hello` skill's session briefing. Open PRs are fetched internally
|
|
10
|
-
* via the configured GitHub token's `repo` scope; callers no longer pass an
|
|
4
|
+
* Exposes `ralph_hero__next_actions` — accepts `audience` param and returns
|
|
5
|
+
* a fixed-shape JSON payload with up to N ranked "directions" for the
|
|
6
|
+
* `hello` skill's session briefing. Open PRs are fetched internally via the
|
|
7
|
+
* configured GitHub token's `repo` scope; callers no longer pass an
|
|
11
8
|
* `openPRs` argument.
|
|
12
9
|
*
|
|
13
10
|
* Behaviour:
|
|
@@ -222,11 +219,8 @@ export async function fetchOpenPRs(client, repos) {
|
|
|
222
219
|
return out;
|
|
223
220
|
}
|
|
224
221
|
// ---------------------------------------------------------------------------
|
|
225
|
-
// Shared implementation — extracted so
|
|
226
|
-
//
|
|
227
|
-
// exported so the deprecated `pick_actionable_issue` wrapper in
|
|
228
|
-
// `issue-tools.ts` can delegate without duplicating the data-fetch +
|
|
229
|
-
// scoring pipeline.
|
|
222
|
+
// Shared implementation — extracted so the `next_actions` tool can route
|
|
223
|
+
// through a single code path.
|
|
230
224
|
// ---------------------------------------------------------------------------
|
|
231
225
|
export function makeRunDirections(client, fieldCache) {
|
|
232
226
|
return async function runDirections(args) {
|
|
@@ -313,49 +307,6 @@ export function makeRunDirections(client, fieldCache) {
|
|
|
313
307
|
// ---------------------------------------------------------------------------
|
|
314
308
|
export function registerDirectionsTools(server, client, fieldCache) {
|
|
315
309
|
const runDirections = makeRunDirections(client, fieldCache);
|
|
316
|
-
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. Open PRs are fetched internally via the configured GitHub token's `repo` scope (one `is:pr is:open repo:owner/name` GraphQL search per unique repo represented in the project items). 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. Returns `{ directions, fetchedAt, boardItems }` where `boardItems` is the raw count of items on the project board pre-filter (uniform across discovery tools); the returned `directions` array is bounded by `limit`.", {
|
|
317
|
-
owner: z
|
|
318
|
-
.string()
|
|
319
|
-
.optional()
|
|
320
|
-
.describe("GitHub owner. Defaults to RALPH_GH_OWNER env var."),
|
|
321
|
-
projectNumbers: z
|
|
322
|
-
.array(z.coerce.number())
|
|
323
|
-
.optional()
|
|
324
|
-
.describe("Project numbers to include. Defaults to RALPH_GH_PROJECT_NUMBERS or single configured project."),
|
|
325
|
-
limit: z
|
|
326
|
-
.number()
|
|
327
|
-
.int()
|
|
328
|
-
.nonnegative()
|
|
329
|
-
.optional()
|
|
330
|
-
.default(3)
|
|
331
|
-
.describe("Max directions to return (default: 3)."),
|
|
332
|
-
stuckThresholdHours: z
|
|
333
|
-
.number()
|
|
334
|
-
.nonnegative()
|
|
335
|
-
.optional()
|
|
336
|
-
.default(48)
|
|
337
|
-
.describe("Hours before a non-lock issue is considered stale (default: 48, unit: hours). Pulls from STUCK_THRESHOLD_HOURS in src/lib/thresholds.ts."),
|
|
338
|
-
lockStaleHours: z
|
|
339
|
-
.number()
|
|
340
|
-
.nonnegative()
|
|
341
|
-
.optional()
|
|
342
|
-
.default(24)
|
|
343
|
-
.describe("Hours before a lock-state issue is considered stalled (default: 24, unit: hours). Pulls from LOCK_STALE_HOURS in src/lib/thresholds.ts."),
|
|
344
|
-
treeRecentDoneDays: z
|
|
345
|
-
.number()
|
|
346
|
-
.nonnegative()
|
|
347
|
-
.optional()
|
|
348
|
-
.default(7)
|
|
349
|
-
.describe("Days within which a sibling Done event still pulls a tree forward (default: 7, unit: days). Pulls from RECENT_WINDOW_DAYS in src/lib/thresholds.ts."),
|
|
350
|
-
prStaleHours: z
|
|
351
|
-
.number()
|
|
352
|
-
.nonnegative()
|
|
353
|
-
.optional()
|
|
354
|
-
.default(24)
|
|
355
|
-
.describe("Hours before an open PR is considered stale (default: 24, unit: hours). Pulls from PR_STALE_HOURS in src/lib/thresholds.ts."),
|
|
356
|
-
}, async (args) => {
|
|
357
|
-
return await runDirections({ ...args, audience: "human" });
|
|
358
|
-
});
|
|
359
310
|
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 are fetched internally via the configured GitHub token's `repo` scope (one `is:pr is:open repo:owner/name` GraphQL search per unique repo represented in the project items) — callers no longer pass an `openPRs` argument. 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. When `audience='agent'` and no items are in actionable phases (Plan in Review, In Review, Ready for Plan, Research Needed) or otherwise surfacing (lock-stale, unblock-requested), the picker falls back to Backlog and null-state items so autopilot can drive triage. Fallback items receive a fixed score penalty so they never outrank actionable items when those exist; the fallback never fires for `audience='human'`. Returns `{ directions, fetchedAt, boardItems }` where `boardItems` is the raw count of items on the project board pre-filter (uniform across discovery tools); the returned `directions` array is bounded by `limit`.", {
|
|
360
311
|
owner: z
|
|
361
312
|
.string()
|
|
@@ -10,7 +10,6 @@ import { detectGroup } from "../lib/group-detection.js";
|
|
|
10
10
|
import { detectPipelinePosition, OVERSIZED_ESTIMATES, } from "../lib/pipeline-detection.js";
|
|
11
11
|
import { isValidState, isParentGateState, VALID_STATES, LOCK_STATES, TERMINAL_STATES, WORKFLOW_STATE_TO_STATUS, } from "../lib/workflow-states.js";
|
|
12
12
|
import { buildBatchMutationQuery } from "./batch-tools.js";
|
|
13
|
-
import { makeRunDirections } from "./directions-tools.js";
|
|
14
13
|
import { resolveState } from "../lib/state-resolution.js";
|
|
15
14
|
import { parseDateMath } from "../lib/date-math.js";
|
|
16
15
|
import { expandProfile } from "../lib/filter-profiles.js";
|
|
@@ -1184,157 +1183,6 @@ export function registerIssueTools(server, client, fieldCache) {
|
|
|
1184
1183
|
return toolError(`Failed to create comment: ${message}`);
|
|
1185
1184
|
}
|
|
1186
1185
|
});
|
|
1187
|
-
// -------------------------------------------------------------------------
|
|
1188
|
-
// ralph_hero__pick_actionable_issue [DEPRECATED]
|
|
1189
|
-
//
|
|
1190
|
-
// Thin wrapper that delegates to the shared `runDirections` helper from
|
|
1191
|
-
// `directions-tools.ts` (audience="agent"). Preserves the legacy
|
|
1192
|
-
// `{ found, issue, group, alternatives }` shape so existing callers
|
|
1193
|
-
// (justfile recipes, hero/team allowlists) keep working until removal in
|
|
1194
|
-
// 2.7.0. Same backwards-compat pattern as `hello_directions` from Phase 2.
|
|
1195
|
-
//
|
|
1196
|
-
// Migration: callers should switch to
|
|
1197
|
-
// ralph_hero__next_actions(limit=1, audience="agent")
|
|
1198
|
-
// and consume the rank-1 (recommended) entry directly.
|
|
1199
|
-
// -------------------------------------------------------------------------
|
|
1200
|
-
const runDirections = makeRunDirections(client, fieldCache);
|
|
1201
|
-
server.tool("ralph_hero__pick_actionable_issue", "[DEPRECATED — use ralph_hero__next_actions(limit=1, audience='agent') instead. Removed in 2.7.0.] Find the highest-priority issue matching a workflow state that is not blocked or locked. Returns: found, issue (with number, title, workflowState, estimate, priority, group context), alternatives count. Used by dispatch loop to find work for idle teammates.", {
|
|
1202
|
-
owner: z
|
|
1203
|
-
.string()
|
|
1204
|
-
.optional()
|
|
1205
|
-
.describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
|
|
1206
|
-
repo: z
|
|
1207
|
-
.string()
|
|
1208
|
-
.optional()
|
|
1209
|
-
.describe("Repository name. Defaults to GITHUB_REPO env var"),
|
|
1210
|
-
projectNumber: z.coerce.number().optional()
|
|
1211
|
-
.describe("Project number override (defaults to configured project)"),
|
|
1212
|
-
workflowState: z
|
|
1213
|
-
.string()
|
|
1214
|
-
.optional()
|
|
1215
|
-
.describe("Target workflow state (e.g., 'Research Needed', 'Ready for Plan'). Optional — when omitted, the rank-1 (recommended) direction across all actionable phases is returned (same as next_actions(limit=1, audience='agent'))."),
|
|
1216
|
-
maxEstimate: z
|
|
1217
|
-
.string()
|
|
1218
|
-
.optional()
|
|
1219
|
-
.default("S")
|
|
1220
|
-
.describe("Maximum estimate to include (XS, S, M, L, XL). Default: S"),
|
|
1221
|
-
}, async (args) => {
|
|
1222
|
-
try {
|
|
1223
|
-
// Validate workflow state (only when provided — wrapper allows
|
|
1224
|
-
// omission so it can mirror next_actions(limit=1, audience='agent')).
|
|
1225
|
-
if (args.workflowState !== undefined && !isValidState(args.workflowState)) {
|
|
1226
|
-
return toolError(`Unknown workflow state '${args.workflowState}'. ` +
|
|
1227
|
-
`Valid states: ${VALID_STATES.join(", ")}. ` +
|
|
1228
|
-
`Recovery: retry with a valid state name. ` +
|
|
1229
|
-
`Common states for dispatch: 'Research Needed' (for researchers), ` +
|
|
1230
|
-
`'Ready for Plan' (for planners), 'Plan in Review' (for reviewers).`);
|
|
1231
|
-
}
|
|
1232
|
-
// Validate estimate
|
|
1233
|
-
const validEstimates = ["XS", "S", "M", "L", "XL"];
|
|
1234
|
-
const maxEstimate = args.maxEstimate || "S";
|
|
1235
|
-
if (!validEstimates.includes(maxEstimate)) {
|
|
1236
|
-
return toolError(`Unknown estimate '${maxEstimate}'. ` +
|
|
1237
|
-
`Valid estimates: ${validEstimates.join(", ")}. ` +
|
|
1238
|
-
`Recovery: retry with a valid estimate or omit for default (S).`);
|
|
1239
|
-
}
|
|
1240
|
-
const { owner, repo } = resolveFullConfig(client, args);
|
|
1241
|
-
// Delegate to the shared ranker with audience="agent". Use a
|
|
1242
|
-
// generous limit so we have enough entries to filter by
|
|
1243
|
-
// workflowState + maxEstimate while still finding the highest
|
|
1244
|
-
// priority candidate. The ranker uses priority + audience-aware
|
|
1245
|
-
// estimate penalty as the dominant ordering signal, which matches
|
|
1246
|
-
// the legacy P0 -> P3 sort.
|
|
1247
|
-
const directionsResult = await runDirections({
|
|
1248
|
-
owner,
|
|
1249
|
-
projectNumbers: args.projectNumber !== undefined ? [args.projectNumber] : undefined,
|
|
1250
|
-
limit: 50,
|
|
1251
|
-
audience: "agent",
|
|
1252
|
-
});
|
|
1253
|
-
if (directionsResult.isError) {
|
|
1254
|
-
// Surface the underlying error verbatim so callers see the same
|
|
1255
|
-
// recovery hints they would from next_actions.
|
|
1256
|
-
return directionsResult;
|
|
1257
|
-
}
|
|
1258
|
-
const payload = JSON.parse(directionsResult.content[0].text);
|
|
1259
|
-
// Restrict to plain "issue" directions — exclude PRs (kind="pr"),
|
|
1260
|
-
// lock-stale items (kind="lock-stale"), and tree-continue picks
|
|
1261
|
-
// (kind="tree-continue") since legacy semantics returned a single
|
|
1262
|
-
// ready-to-claim issue.
|
|
1263
|
-
let issueDirections = payload.directions.filter((d) => d.kind === "issue" && d.issue !== null);
|
|
1264
|
-
// Apply legacy filters: workflowState (if provided) and maxEstimate.
|
|
1265
|
-
if (args.workflowState !== undefined) {
|
|
1266
|
-
issueDirections = issueDirections.filter((d) => d.issue.workflowState === args.workflowState);
|
|
1267
|
-
}
|
|
1268
|
-
const maxIdx = validEstimates.indexOf(maxEstimate);
|
|
1269
|
-
issueDirections = issueDirections.filter((d) => {
|
|
1270
|
-
const est = d.issue.estimate;
|
|
1271
|
-
if (!est)
|
|
1272
|
-
return true;
|
|
1273
|
-
const estIdx = validEstimates.indexOf(est);
|
|
1274
|
-
if (estIdx < 0)
|
|
1275
|
-
return true;
|
|
1276
|
-
return estIdx <= maxIdx;
|
|
1277
|
-
});
|
|
1278
|
-
// Drop blocked items (legacy behavior). The ranker already strips
|
|
1279
|
-
// these unless that would empty the candidate set; the "blocked"
|
|
1280
|
-
// tag still rides along when it kept them as a fallback.
|
|
1281
|
-
issueDirections = issueDirections.filter((d) => !d.tags.includes("blocked"));
|
|
1282
|
-
if (issueDirections.length === 0) {
|
|
1283
|
-
return toolSuccess({
|
|
1284
|
-
found: false,
|
|
1285
|
-
issue: null,
|
|
1286
|
-
alternatives: 0,
|
|
1287
|
-
});
|
|
1288
|
-
}
|
|
1289
|
-
const best = issueDirections[0].issue;
|
|
1290
|
-
const issueNumber = best.number;
|
|
1291
|
-
// Detect group context for the picked issue (best-effort).
|
|
1292
|
-
let group = null;
|
|
1293
|
-
try {
|
|
1294
|
-
const groupResult = await detectGroup(client, owner, repo, issueNumber);
|
|
1295
|
-
group = {
|
|
1296
|
-
isGroup: groupResult.isGroup,
|
|
1297
|
-
primary: {
|
|
1298
|
-
number: groupResult.groupPrimary.number,
|
|
1299
|
-
title: groupResult.groupPrimary.title,
|
|
1300
|
-
},
|
|
1301
|
-
members: groupResult.groupTickets.map((t) => ({
|
|
1302
|
-
number: t.number,
|
|
1303
|
-
title: t.title,
|
|
1304
|
-
state: t.state,
|
|
1305
|
-
order: t.order,
|
|
1306
|
-
})),
|
|
1307
|
-
totalTickets: groupResult.totalTickets,
|
|
1308
|
-
};
|
|
1309
|
-
}
|
|
1310
|
-
catch {
|
|
1311
|
-
// Group detection is best-effort
|
|
1312
|
-
group = null;
|
|
1313
|
-
}
|
|
1314
|
-
return toolSuccess({
|
|
1315
|
-
found: true,
|
|
1316
|
-
issue: {
|
|
1317
|
-
number: issueNumber,
|
|
1318
|
-
title: best.title,
|
|
1319
|
-
// Body is not fetched by the ranker's data pipeline — kept
|
|
1320
|
-
// empty for backward compat. Callers needing the body should
|
|
1321
|
-
// chain a `get_issue` call (or migrate to `next_actions`).
|
|
1322
|
-
description: "",
|
|
1323
|
-
workflowState: best.workflowState,
|
|
1324
|
-
estimate: best.estimate || null,
|
|
1325
|
-
priority: best.priority || null,
|
|
1326
|
-
isLocked: false,
|
|
1327
|
-
blockedBy: [],
|
|
1328
|
-
},
|
|
1329
|
-
group,
|
|
1330
|
-
alternatives: issueDirections.length - 1,
|
|
1331
|
-
});
|
|
1332
|
-
}
|
|
1333
|
-
catch (error) {
|
|
1334
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1335
|
-
return toolError(`Failed to pick actionable issue: ${message}`);
|
|
1336
|
-
}
|
|
1337
|
-
});
|
|
1338
1186
|
}
|
|
1339
1187
|
function getFieldValue(item, fieldName) {
|
|
1340
1188
|
const fieldValue = item.fieldValues.nodes.find((fv) => fv.field?.name === fieldName &&
|