ralph-hero-mcp-server 2.5.79 → 2.5.81

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 CHANGED
@@ -28,6 +28,7 @@ import { registerDebugTools } from "./tools/debug-tools.js";
28
28
  import { registerDecomposeTools } from "./tools/decompose-tools.js";
29
29
  import { registerViewTools } from "./tools/view-tools.js";
30
30
  import { registerPlanGraphTools } from "./tools/plan-graph-tools.js";
31
+ import { registerActivityTools } from "./tools/activity-tools.js";
31
32
  /**
32
33
  * Initialize the GitHub client from environment variables.
33
34
  */
@@ -401,6 +402,8 @@ async function main() {
401
402
  registerViewTools(server, client, fieldCache);
402
403
  // Plan graph sync tool (sync plan dependency edges to GitHub)
403
404
  registerPlanGraphTools(server, client);
405
+ // Activity log reader (recent_activity tool — pure filesystem, no GitHub client)
406
+ registerActivityTools(server);
404
407
  // Debug tools (only when RALPH_DEBUG=true)
405
408
  if (process.env.RALPH_DEBUG === 'true') {
406
409
  registerDebugTools(server, client);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Pure read library for the local ralph-hero activity log.
3
+ *
4
+ * The log lives at `~/.ralph-hero/activity/YYYY/MM/DD.jsonl` (or a
5
+ * configurable root). One JSON object per line, append-only. Hooks
6
+ * write the file via `record-activity.sh`; this library only reads.
7
+ *
8
+ * Determinism: pure functions. Time is injected via `ActivityReadConfig.now`
9
+ * for tests. Filesystem reads are the only side effect.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ export function readActivity(config) {
14
+ if (!fs.existsSync(config.rootDir)) {
15
+ return { events: [], cursor_advanced_to: null, skipped_lines: 0 };
16
+ }
17
+ const sinceTs = config.since ? new Date(config.since).getTime() : 0;
18
+ const untilTs = config.until ? new Date(config.until).getTime() : Number.MAX_SAFE_INTEGER;
19
+ if (config.since && Number.isNaN(sinceTs)) {
20
+ throw new Error(`Invalid 'since' format: ${config.since}`);
21
+ }
22
+ if (config.until && Number.isNaN(untilTs)) {
23
+ throw new Error(`Invalid 'until' format: ${config.until}`);
24
+ }
25
+ const events = [];
26
+ let skipped = 0;
27
+ // Walk YYYY/MM/DD structure
28
+ const years = safeReadDir(config.rootDir).filter((d) => /^\d{4}$/.test(d)).sort();
29
+ for (const y of years) {
30
+ const yDir = path.join(config.rootDir, y);
31
+ const months = safeReadDir(yDir).filter((d) => /^\d{2}$/.test(d)).sort();
32
+ for (const m of months) {
33
+ const mDir = path.join(yDir, m);
34
+ const days = safeReadDir(mDir).filter((d) => /^\d{2}\.jsonl$/.test(d)).sort();
35
+ for (const dFile of days) {
36
+ const filePath = path.join(mDir, dFile);
37
+ const content = safeReadFile(filePath);
38
+ if (content === null)
39
+ continue;
40
+ for (const line of content.split("\n")) {
41
+ if (line.trim() === "")
42
+ continue;
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(line);
46
+ }
47
+ catch {
48
+ skipped++;
49
+ continue;
50
+ }
51
+ const eventTs = new Date(parsed.ts).getTime();
52
+ if (Number.isNaN(eventTs)) {
53
+ skipped++;
54
+ continue;
55
+ }
56
+ if (eventTs < sinceTs || eventTs > untilTs)
57
+ continue;
58
+ if (config.category !== "all" && parsed.category !== config.category)
59
+ continue;
60
+ if (config.kinds !== null && !config.kinds.includes(parsed.kind))
61
+ continue;
62
+ if (config.project !== null && parsed.project !== config.project)
63
+ continue;
64
+ events.push(parsed);
65
+ }
66
+ }
67
+ }
68
+ }
69
+ events.sort((a, b) => a.ts.localeCompare(b.ts));
70
+ const limited = events.slice(0, config.limit);
71
+ const cursor = limited.length > 0 ? limited[limited.length - 1].ts : null;
72
+ return { events: limited, cursor_advanced_to: cursor, skipped_lines: skipped };
73
+ }
74
+ function safeReadDir(dir) {
75
+ try {
76
+ return fs.readdirSync(dir);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ function safeReadFile(filePath) {
83
+ try {
84
+ return fs.readFileSync(filePath, "utf-8");
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ //# sourceMappingURL=activity.js.map
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import { readActivity } from "../lib/activity.js";
5
+ import { toolSuccess, toolError } from "../types.js";
6
+ function defaultActivityRoot() {
7
+ return process.env.RALPH_ACTIVITY_DIR ?? path.join(os.homedir(), ".ralph-hero", "activity");
8
+ }
9
+ export function registerActivityTools(server) {
10
+ server.tool("ralph_hero__recent_activity", "Read structured events from the local ralph-hero activity log since a cursor. Used by /catch-up to synthesize 'what changed since last time' narratives. Pure read; the log is written by harness hooks.", {
11
+ since: z.string().nullable().default(null).describe("ISO8601 timestamp lower bound; null = all of today"),
12
+ until: z.string().nullable().default(null).describe("Optional ISO8601 upper bound"),
13
+ kinds: z.array(z.string()).nullable().default(null).describe("Filter by event kind (e.g., ['pr_opened','issue_advanced'])"),
14
+ category: z.enum(["work", "meta", "all"]).default("work").describe("Filter by category; default 'work' excludes meta noise"),
15
+ project: z.string().nullable().default(null).describe("Filter by project name"),
16
+ limit: z.number().int().min(1).default(100).describe("Max events to return"),
17
+ }, async (params) => {
18
+ try {
19
+ const result = readActivity({
20
+ rootDir: defaultActivityRoot(),
21
+ since: params.since ?? null,
22
+ until: params.until ?? null,
23
+ kinds: params.kinds ?? null,
24
+ category: (params.category ?? "work"),
25
+ project: params.project ?? null,
26
+ limit: params.limit ?? 100,
27
+ now: new Date(),
28
+ });
29
+ return toolSuccess(result);
30
+ }
31
+ catch (err) {
32
+ return toolError(err instanceof Error ? err.message : String(err));
33
+ }
34
+ });
35
+ }
36
+ //# sourceMappingURL=activity-tools.js.map
@@ -51,10 +51,12 @@ const openPRSchema = z.object({
51
51
  });
52
52
  // ---------------------------------------------------------------------------
53
53
  // Shared implementation — extracted so both `hello_directions` (deprecated)
54
- // and `next_actions` (current) can route through the same code path. Keep
55
- // file-private (no export) alias lives in this same file.
54
+ // and `next_actions` (current) can route through the same code path. Also
55
+ // exported so the deprecated `pick_actionable_issue` wrapper in
56
+ // `issue-tools.ts` can delegate without duplicating the data-fetch +
57
+ // scoring pipeline.
56
58
  // ---------------------------------------------------------------------------
57
- function makeRunDirections(client, fieldCache) {
59
+ export function makeRunDirections(client, fieldCache) {
58
60
  return async function runDirections(args) {
59
61
  try {
60
62
  const owner = args.owner || resolveProjectOwner(client.config);
@@ -10,6 +10,7 @@ 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";
13
14
  import { resolveState } from "../lib/state-resolution.js";
14
15
  import { parseDateMath } from "../lib/date-math.js";
15
16
  import { expandProfile } from "../lib/filter-profiles.js";
@@ -1185,9 +1186,20 @@ export function registerIssueTools(server, client, fieldCache) {
1185
1186
  }
1186
1187
  });
1187
1188
  // -------------------------------------------------------------------------
1188
- // ralph_hero__pick_actionable_issue
1189
+ // ralph_hero__pick_actionable_issue [DEPRECATED]
1190
+ //
1191
+ // Thin wrapper that delegates to the shared `runDirections` helper from
1192
+ // `directions-tools.ts` (audience="agent"). Preserves the legacy
1193
+ // `{ found, issue, group, alternatives }` shape so existing callers
1194
+ // (justfile recipes, hero/team allowlists) keep working until removal in
1195
+ // 2.7.0. Same backwards-compat pattern as `hello_directions` from Phase 2.
1196
+ //
1197
+ // Migration: callers should switch to
1198
+ // ralph_hero__next_actions(limit=1, audience="agent")
1199
+ // and consume the rank-1 (recommended) entry directly.
1189
1200
  // -------------------------------------------------------------------------
1190
- server.tool("ralph_hero__pick_actionable_issue", "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. Recovery: if no issues found, try a different workflowState or increase maxEstimate.", {
1201
+ const runDirections = makeRunDirections(client, fieldCache);
1202
+ 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.", {
1191
1203
  owner: z
1192
1204
  .string()
1193
1205
  .optional()
@@ -1200,7 +1212,8 @@ export function registerIssueTools(server, client, fieldCache) {
1200
1212
  .describe("Project number override (defaults to configured project)"),
1201
1213
  workflowState: z
1202
1214
  .string()
1203
- .describe("Target workflow state (e.g., 'Research Needed', 'Ready for Plan')"),
1215
+ .optional()
1216
+ .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'))."),
1204
1217
  maxEstimate: z
1205
1218
  .string()
1206
1219
  .optional()
@@ -1208,8 +1221,9 @@ export function registerIssueTools(server, client, fieldCache) {
1208
1221
  .describe("Maximum estimate to include (XS, S, M, L, XL). Default: S"),
1209
1222
  }, async (args) => {
1210
1223
  try {
1211
- // Validate workflow state
1212
- if (!isValidState(args.workflowState)) {
1224
+ // Validate workflow state (only when provided — wrapper allows
1225
+ // omission so it can mirror next_actions(limit=1, audience='agent')).
1226
+ if (args.workflowState !== undefined && !isValidState(args.workflowState)) {
1213
1227
  return toolError(`Unknown workflow state '${args.workflowState}'. ` +
1214
1228
  `Valid states: ${VALID_STATES.join(", ")}. ` +
1215
1229
  `Recovery: retry with a valid state name. ` +
@@ -1224,111 +1238,59 @@ export function registerIssueTools(server, client, fieldCache) {
1224
1238
  `Valid estimates: ${validEstimates.join(", ")}. ` +
1225
1239
  `Recovery: retry with a valid estimate or omit for default (S).`);
1226
1240
  }
1227
- const { owner, repo, projectNumber, projectOwner } = resolveFullConfig(client, args);
1228
- // Ensure field cache is populated
1229
- await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
1230
- const projectId = fieldCache.getProjectId(projectNumber);
1231
- if (!projectId) {
1232
- return toolError("Could not resolve project ID");
1241
+ const { owner, repo } = resolveFullConfig(client, args);
1242
+ // Delegate to the shared ranker with audience="agent". Use a
1243
+ // generous limit so we have enough entries to filter by
1244
+ // workflowState + maxEstimate while still finding the highest
1245
+ // priority candidate. The ranker uses priority + audience-aware
1246
+ // estimate penalty as the dominant ordering signal, which matches
1247
+ // the legacy P0 -> P3 sort.
1248
+ const directionsResult = await runDirections({
1249
+ owner,
1250
+ projectNumbers: args.projectNumber !== undefined ? [args.projectNumber] : undefined,
1251
+ limit: 50,
1252
+ openPRs: [],
1253
+ audience: "agent",
1254
+ });
1255
+ if (directionsResult.isError) {
1256
+ // Surface the underlying error verbatim so callers see the same
1257
+ // recovery hints they would from next_actions.
1258
+ return directionsResult;
1233
1259
  }
1234
- // Fetch all project items
1235
- const itemsResult = await paginateConnection((q, v) => client.projectQuery(q, v), `query($projectId: ID!, $cursor: String, $first: Int!) {
1236
- node(id: $projectId) {
1237
- ... on ProjectV2 {
1238
- items(first: $first, after: $cursor) {
1239
- totalCount
1240
- pageInfo { hasNextPage endCursor }
1241
- nodes {
1242
- id
1243
- type
1244
- content {
1245
- ... on Issue {
1246
- number
1247
- title
1248
- body
1249
- state
1250
- url
1251
- labels(first: 10) { nodes { name } }
1252
- trackedIssues(first: 10) {
1253
- nodes { number state }
1254
- }
1255
- }
1256
- }
1257
- fieldValues(first: 20) {
1258
- nodes {
1259
- ... on ProjectV2ItemFieldSingleSelectValue {
1260
- __typename
1261
- name
1262
- optionId
1263
- field { ... on ProjectV2FieldCommon { name } }
1264
- }
1265
- }
1266
- }
1267
- }
1268
- }
1269
- }
1260
+ const payload = JSON.parse(directionsResult.content[0].text);
1261
+ // Restrict to plain "issue" directions exclude PRs (kind="pr"),
1262
+ // lock-stale items (kind="lock-stale"), and tree-continue picks
1263
+ // (kind="tree-continue") since legacy semantics returned a single
1264
+ // ready-to-claim issue.
1265
+ let issueDirections = payload.directions.filter((d) => d.kind === "issue" && d.issue !== null);
1266
+ // Apply legacy filters: workflowState (if provided) and maxEstimate.
1267
+ if (args.workflowState !== undefined) {
1268
+ issueDirections = issueDirections.filter((d) => d.issue.workflowState === args.workflowState);
1270
1269
  }
1271
- }`, { projectId, first: 100 }, "node.items", { maxItems: 500 });
1272
- // Filter to matching items
1273
- let candidates = itemsResult.nodes.filter((item) => {
1274
- if (item.type !== "ISSUE" || !item.content)
1275
- return false;
1276
- const content = item.content;
1277
- if (content.state !== "OPEN")
1278
- return false;
1279
- // Check workflow state
1280
- const ws = getFieldValue(item, "Workflow State");
1281
- if (ws !== args.workflowState)
1282
- return false;
1283
- // Check estimate
1284
- const est = getFieldValue(item, "Estimate");
1285
- if (est) {
1286
- const estIdx = validEstimates.indexOf(est);
1287
- const maxIdx = validEstimates.indexOf(maxEstimate);
1288
- if (estIdx > maxIdx)
1289
- return false;
1290
- }
1291
- return true;
1292
- });
1293
- // Filter out locked issues (in a lock state - shouldn't happen if matching target state, but safety check)
1294
- candidates = candidates.filter((item) => {
1295
- const ws = getFieldValue(item, "Workflow State");
1296
- return !ws || !LOCK_STATES.includes(ws);
1297
- });
1298
- // Filter out issues with unresolved blockers
1299
- candidates = candidates.filter((item) => {
1300
- const content = item.content;
1301
- const blockedBy = content.trackedIssues;
1302
- if (!blockedBy?.nodes || blockedBy.nodes.length === 0)
1270
+ const maxIdx = validEstimates.indexOf(maxEstimate);
1271
+ issueDirections = issueDirections.filter((d) => {
1272
+ const est = d.issue.estimate;
1273
+ if (!est)
1303
1274
  return true;
1304
- // Issue is blocked if any blocker is still OPEN
1305
- return !blockedBy.nodes.some((dep) => dep.state === "OPEN");
1306
- });
1307
- // Sort by priority (P0 > P1 > P2 > P3 > none)
1308
- const priorityOrder = {
1309
- P0: 0,
1310
- P1: 1,
1311
- P2: 2,
1312
- P3: 3,
1313
- };
1314
- candidates.sort((a, b) => {
1315
- const pA = getFieldValue(a, "Priority");
1316
- const pB = getFieldValue(b, "Priority");
1317
- const orderA = pA ? (priorityOrder[pA] ?? 99) : 99;
1318
- const orderB = pB ? (priorityOrder[pB] ?? 99) : 99;
1319
- return orderA - orderB;
1275
+ const estIdx = validEstimates.indexOf(est);
1276
+ if (estIdx < 0)
1277
+ return true;
1278
+ return estIdx <= maxIdx;
1320
1279
  });
1321
- if (candidates.length === 0) {
1280
+ // Drop blocked items (legacy behavior). The ranker already strips
1281
+ // these unless that would empty the candidate set; the "blocked"
1282
+ // tag still rides along when it kept them as a fallback.
1283
+ issueDirections = issueDirections.filter((d) => !d.tags.includes("blocked"));
1284
+ if (issueDirections.length === 0) {
1322
1285
  return toolSuccess({
1323
1286
  found: false,
1324
1287
  issue: null,
1325
1288
  alternatives: 0,
1326
1289
  });
1327
1290
  }
1328
- const best = candidates[0];
1329
- const content = best.content;
1330
- const issueNumber = content.number;
1331
- // Detect group context for the picked issue (best-effort)
1291
+ const best = issueDirections[0].issue;
1292
+ const issueNumber = best.number;
1293
+ // Detect group context for the picked issue (best-effort).
1332
1294
  let group = null;
1333
1295
  try {
1334
1296
  const groupResult = await detectGroup(client, owner, repo, issueNumber);
@@ -1355,16 +1317,19 @@ export function registerIssueTools(server, client, fieldCache) {
1355
1317
  found: true,
1356
1318
  issue: {
1357
1319
  number: issueNumber,
1358
- title: content.title,
1359
- description: content.body || "",
1360
- workflowState: getFieldValue(best, "Workflow State"),
1361
- estimate: getFieldValue(best, "Estimate") || null,
1362
- priority: getFieldValue(best, "Priority") || null,
1320
+ title: best.title,
1321
+ // Body is not fetched by the ranker's data pipeline — kept
1322
+ // empty for backward compat. Callers needing the body should
1323
+ // chain a `get_issue` call (or migrate to `next_actions`).
1324
+ description: "",
1325
+ workflowState: best.workflowState,
1326
+ estimate: best.estimate || null,
1327
+ priority: best.priority || null,
1363
1328
  isLocked: false,
1364
1329
  blockedBy: [],
1365
1330
  },
1366
1331
  group,
1367
- alternatives: candidates.length - 1,
1332
+ alternatives: issueDirections.length - 1,
1368
1333
  });
1369
1334
  }
1370
1335
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.79",
3
+ "version": "2.5.81",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",