@gugu910/pi-slack-bridge 0.1.3

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +299 -0
  3. package/dist/activity-log.js +304 -0
  4. package/dist/broker/adapters/slack.js +645 -0
  5. package/dist/broker/adapters/types.js +3 -0
  6. package/dist/broker/agent-messaging.js +154 -0
  7. package/dist/broker/auth.js +97 -0
  8. package/dist/broker/client.js +495 -0
  9. package/dist/broker/control-plane-canvas.js +357 -0
  10. package/dist/broker/index.js +125 -0
  11. package/dist/broker/leader.js +133 -0
  12. package/dist/broker/maintenance.js +135 -0
  13. package/dist/broker/paths.js +69 -0
  14. package/dist/broker/router.js +287 -0
  15. package/dist/broker/schema.js +1492 -0
  16. package/dist/broker/socket-server.js +665 -0
  17. package/dist/broker/types.js +12 -0
  18. package/dist/broker-delivery.js +34 -0
  19. package/dist/canvases.js +175 -0
  20. package/dist/deploy-manifest.js +238 -0
  21. package/dist/follower-delivery.js +83 -0
  22. package/dist/git-metadata.js +95 -0
  23. package/dist/guardrails.js +197 -0
  24. package/dist/helpers.js +2128 -0
  25. package/dist/home-tab.js +240 -0
  26. package/dist/index.js +3086 -0
  27. package/dist/pinet-commands.js +244 -0
  28. package/dist/ralph-loop.js +385 -0
  29. package/dist/reaction-triggers.js +160 -0
  30. package/dist/scheduled-wakeups.js +71 -0
  31. package/dist/slack-api.js +5 -0
  32. package/dist/slack-block-kit.js +425 -0
  33. package/dist/slack-export.js +214 -0
  34. package/dist/slack-modals.js +269 -0
  35. package/dist/slack-presence.js +98 -0
  36. package/dist/slack-socket-dedup.js +143 -0
  37. package/dist/slack-tools.js +1715 -0
  38. package/dist/slack-upload.js +147 -0
  39. package/dist/task-assignments.js +403 -0
  40. package/dist/ttl-cache.js +110 -0
  41. package/manifest.yaml +57 -0
  42. package/package.json +45 -0
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.inferSlackUploadFiletype = inferSlackUploadFiletype;
7
+ exports.chooseSlackSnippetType = chooseSlackSnippetType;
8
+ exports.resolveSlackUploadPath = resolveSlackUploadPath;
9
+ exports.prepareSlackUpload = prepareSlackUpload;
10
+ exports.performSlackUpload = performSlackUpload;
11
+ const promises_1 = require("node:fs/promises");
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const FILETYPE_ALIASES = {
14
+ cjs: "javascript",
15
+ htm: "html",
16
+ js: "javascript",
17
+ jsx: "javascript",
18
+ mjs: "javascript",
19
+ md: "markdown",
20
+ py: "python",
21
+ rb: "ruby",
22
+ sh: "shell",
23
+ ts: "typescript",
24
+ tsx: "typescript",
25
+ yml: "yaml",
26
+ zsh: "shell",
27
+ };
28
+ const DEFAULT_SNIPPET_TYPE = "text";
29
+ const MAX_SNIPPET_BYTES = 1_000_000;
30
+ function inferSlackUploadFiletype(filename, explicitFiletype) {
31
+ const raw = (explicitFiletype ?? node_path_1.default.extname(filename ?? "").slice(1)).trim().toLowerCase();
32
+ if (!raw)
33
+ return undefined;
34
+ return FILETYPE_ALIASES[raw] ?? raw;
35
+ }
36
+ function chooseSlackSnippetType(upload) {
37
+ if (upload.source !== "content")
38
+ return undefined;
39
+ if (upload.byteLength > MAX_SNIPPET_BYTES)
40
+ return undefined;
41
+ return inferSlackUploadFiletype(upload.filename, upload.filetype) ?? DEFAULT_SNIPPET_TYPE;
42
+ }
43
+ function isWithinRoot(candidate, root) {
44
+ return candidate === root || candidate.startsWith(`${root}${node_path_1.default.sep}`);
45
+ }
46
+ async function resolveSlackUploadPath(inputPath, cwd, tmpdir, fsDeps = {}) {
47
+ const { realpathImpl = promises_1.realpath, statImpl = promises_1.stat } = fsDeps;
48
+ const requestedPath = inputPath.trim();
49
+ if (!requestedPath) {
50
+ throw new Error("path is required when uploading from a local file.");
51
+ }
52
+ const candidate = node_path_1.default.isAbsolute(requestedPath)
53
+ ? requestedPath
54
+ : node_path_1.default.resolve(cwd, requestedPath);
55
+ const [resolvedCandidate, resolvedCwd, resolvedTmpdir] = await Promise.all([
56
+ realpathImpl(candidate),
57
+ realpathImpl(cwd),
58
+ realpathImpl(tmpdir),
59
+ ]);
60
+ if (!isWithinRoot(resolvedCandidate, resolvedCwd) &&
61
+ !isWithinRoot(resolvedCandidate, resolvedTmpdir)) {
62
+ throw new Error("For safety, slack_upload only allows local file paths inside the current working directory or the system temp directory. For other files, read the content explicitly and upload it via the content parameter.");
63
+ }
64
+ const fileStats = await statImpl(resolvedCandidate);
65
+ if (!fileStats.isFile()) {
66
+ throw new Error(`Local upload path is not a file: ${requestedPath}`);
67
+ }
68
+ return resolvedCandidate;
69
+ }
70
+ async function prepareSlackUpload(params, cwd, tmpdir, fsDeps = {}) {
71
+ const { readFileImpl = promises_1.readFile } = fsDeps;
72
+ const hasContent = typeof params.content === "string";
73
+ const hasPath = typeof params.path === "string" && params.path.trim().length > 0;
74
+ if (hasContent === hasPath) {
75
+ throw new Error("Provide exactly one of content or path.");
76
+ }
77
+ let bytes;
78
+ let filename = params.filename?.trim();
79
+ let resolvedPath;
80
+ let source;
81
+ if (hasContent) {
82
+ if (!filename) {
83
+ throw new Error("filename is required when uploading inline content.");
84
+ }
85
+ bytes = Buffer.from(params.content ?? "", "utf8");
86
+ source = "content";
87
+ }
88
+ else {
89
+ resolvedPath = await resolveSlackUploadPath(params.path, cwd, tmpdir, fsDeps);
90
+ bytes = await readFileImpl(resolvedPath);
91
+ filename = filename || node_path_1.default.basename(resolvedPath);
92
+ source = "path";
93
+ }
94
+ const sanitizedFilename = filename?.trim();
95
+ if (!sanitizedFilename) {
96
+ throw new Error("filename is required.");
97
+ }
98
+ const filetype = inferSlackUploadFiletype(sanitizedFilename, params.filetype);
99
+ const title = params.title?.trim() || sanitizedFilename;
100
+ return {
101
+ bytes,
102
+ byteLength: bytes.byteLength,
103
+ filename: sanitizedFilename,
104
+ title,
105
+ filetype,
106
+ snippetType: chooseSlackSnippetType({
107
+ source,
108
+ byteLength: bytes.byteLength,
109
+ filename: sanitizedFilename,
110
+ filetype,
111
+ }),
112
+ source,
113
+ ...(resolvedPath ? { resolvedPath } : {}),
114
+ };
115
+ }
116
+ async function performSlackUpload({ upload, channelId, threadTs, slack, token, fetchImpl = fetch, }) {
117
+ const getUploadResponse = await slack("files.getUploadURLExternal", token, {
118
+ filename: upload.filename,
119
+ length: upload.byteLength,
120
+ ...(upload.snippetType ? { snippet_type: upload.snippetType } : {}),
121
+ });
122
+ const uploadUrl = typeof getUploadResponse.upload_url === "string" ? getUploadResponse.upload_url : null;
123
+ const fileId = typeof getUploadResponse.file_id === "string" ? getUploadResponse.file_id : null;
124
+ if (!uploadUrl || !fileId) {
125
+ throw new Error("Slack files.getUploadURLExternal did not return an upload URL and file ID.");
126
+ }
127
+ const rawBody = new Uint8Array(upload.bytes.byteLength);
128
+ rawBody.set(upload.bytes);
129
+ const rawUploadResponse = await fetchImpl(uploadUrl, {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Length": String(upload.byteLength),
133
+ "Content-Type": upload.source === "content" ? "text/plain; charset=utf-8" : "application/octet-stream",
134
+ },
135
+ body: new Blob([rawBody]),
136
+ });
137
+ if (!rawUploadResponse.ok) {
138
+ const details = (await rawUploadResponse.text()).trim();
139
+ throw new Error(`Slack raw upload failed (${rawUploadResponse.status}${rawUploadResponse.statusText ? ` ${rawUploadResponse.statusText}` : ""})${details ? `: ${details}` : ""}`);
140
+ }
141
+ const response = await slack("files.completeUploadExternal", token, {
142
+ files: [{ id: fileId, title: upload.title }],
143
+ channel_id: channelId,
144
+ ...(threadTs ? { thread_ts: threadTs } : {}),
145
+ });
146
+ return { fileId, response };
147
+ }
@@ -0,0 +1,403 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractTaskAssignmentsFromMessage = extractTaskAssignmentsFromMessage;
4
+ exports.normalizeTrackedTaskAssignments = normalizeTrackedTaskAssignments;
5
+ exports.resolveTaskAssignments = resolveTaskAssignments;
6
+ exports.hasTaskAssignmentStatusChange = hasTaskAssignmentStatusChange;
7
+ exports.buildTaskAssignmentReport = buildTaskAssignmentReport;
8
+ exports.getPendingTaskAssignmentReport = getPendingTaskAssignmentReport;
9
+ const node_child_process_1 = require("node:child_process");
10
+ const node_util_1 = require("node:util");
11
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
12
+ const WORKTREE_BRANCH_REGEX = /\bgit\s+worktree\s+add\b[^\n]*?\s-b\s+([^\s`"',;]+)/i;
13
+ const CHECKOUT_BRANCH_REGEX = /\bgit\s+(?:checkout|switch)\s+-[cb]\s+([^\s`"',;]+)/i;
14
+ const EXPLICIT_BRANCH_LABEL_REGEX = /\bbranch(?:\s+to\s+work\s+on|\s+name)?\s*:\s*[`"']?([A-Za-z0-9._/-]+)\b/i;
15
+ const ISSUE_PR_LINE_REGEX = /(?:^|\n)\s*(?:[-*]\s*)?issue\/pr\s*:\s*#(\d+)\b/gi;
16
+ const ISSUE_LINE_REGEX = /(?:^|\n)\s*(?:[-*]\s*)?issue\s*:\s*#(\d+)\b/gi;
17
+ const ISSUE_HEADING_REGEX = /(?:^|\n)\s*(?:[-*]\s*)?issue\s+#(\d+)(?=\s*(?:[—–:-]|$))/gi;
18
+ const TASK_ISSUE_REGEX = /(?:^|\n)\s*(?:[-*]\s*)?task\s*:\s*[^\n#]*\bissue\s*#(\d+)\b/gi;
19
+ const NEW_TASK_ISSUE_REGEX = /(?:^|\n)\s*(?:[-*]\s*)?new(?:\s+[a-z-]+){0,3}\s+task\b[^\n#]*\bissue\s*#(\d+)\b/gi;
20
+ const FOLLOW_UP_TASK_ISSUE_REGEX = /(?:^|\n)\s*(?:[-*]\s*)?follow-up\s+task(?:\s+from)?\s+issue\s*#(\d+)\b/gi;
21
+ async function runCommand(file, args, cwd, runner) {
22
+ try {
23
+ const result = await runner(file, args, { cwd, encoding: "utf-8" });
24
+ const stdout = typeof result.stdout === "string" ? result.stdout : result.stdout?.toString();
25
+ const trimmed = stdout?.trim();
26
+ return trimmed ? trimmed : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ async function runJsonCommand(file, args, cwd, runner) {
33
+ const stdout = await runCommand(file, args, cwd, runner);
34
+ if (!stdout) {
35
+ return undefined;
36
+ }
37
+ try {
38
+ return JSON.parse(stdout);
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function normalizeMessageForTaskParsing(message) {
45
+ return message.replace(/\r\n/g, "\n").replace(/[`*_]/g, "");
46
+ }
47
+ function parseBranch(message) {
48
+ const normalized = normalizeMessageForTaskParsing(message);
49
+ const match = normalized.match(WORKTREE_BRANCH_REGEX) ??
50
+ normalized.match(CHECKOUT_BRANCH_REGEX) ??
51
+ normalized.match(EXPLICIT_BRANCH_LABEL_REGEX);
52
+ const branch = match?.[1]?.trim().replace(/[.,;:]+$/, "");
53
+ return branch ? branch : null;
54
+ }
55
+ function parseIssueNumbers(message) {
56
+ const normalized = normalizeMessageForTaskParsing(message);
57
+ const issueNumbers = new Set();
58
+ for (const regex of [
59
+ ISSUE_PR_LINE_REGEX,
60
+ ISSUE_LINE_REGEX,
61
+ ISSUE_HEADING_REGEX,
62
+ TASK_ISSUE_REGEX,
63
+ NEW_TASK_ISSUE_REGEX,
64
+ FOLLOW_UP_TASK_ISSUE_REGEX,
65
+ ]) {
66
+ for (const match of normalized.matchAll(regex)) {
67
+ const issueNumber = Number(match[1]);
68
+ if (Number.isFinite(issueNumber)) {
69
+ issueNumbers.add(issueNumber);
70
+ }
71
+ }
72
+ }
73
+ return [...issueNumbers].sort((left, right) => left - right);
74
+ }
75
+ function extractTaskAssignmentsFromMessage(message) {
76
+ const branch = parseBranch(message);
77
+ return parseIssueNumbers(message).map((issueNumber) => ({ issueNumber, branch }));
78
+ }
79
+ function compareTaskAssignmentRecency(left, right) {
80
+ const updatedAt = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
81
+ if (updatedAt !== 0) {
82
+ return updatedAt;
83
+ }
84
+ const createdAt = Date.parse(right.createdAt) - Date.parse(left.createdAt);
85
+ if (createdAt !== 0) {
86
+ return createdAt;
87
+ }
88
+ return right.id - left.id;
89
+ }
90
+ function canonicalizeTaskAssignmentFromSourceMessage(assignment, sourceMessagesById) {
91
+ if (assignment.sourceMessageId == null) {
92
+ return assignment;
93
+ }
94
+ const sourceMessage = sourceMessagesById.get(assignment.sourceMessageId);
95
+ if (!sourceMessage) {
96
+ return assignment;
97
+ }
98
+ const parsedAssignments = extractTaskAssignmentsFromMessage(sourceMessage);
99
+ if (parsedAssignments.length === 0) {
100
+ return null;
101
+ }
102
+ const matchingAssignment = parsedAssignments.find((candidate) => candidate.issueNumber === assignment.issueNumber);
103
+ if (matchingAssignment) {
104
+ if (matchingAssignment.branch === assignment.branch) {
105
+ return assignment;
106
+ }
107
+ return { ...assignment, branch: matchingAssignment.branch };
108
+ }
109
+ if (parsedAssignments.length === 1) {
110
+ const [canonicalAssignment] = parsedAssignments;
111
+ return {
112
+ ...assignment,
113
+ issueNumber: canonicalAssignment.issueNumber,
114
+ branch: canonicalAssignment.branch,
115
+ };
116
+ }
117
+ return null;
118
+ }
119
+ function normalizeTrackedTaskAssignments(assignments, sourceMessagesById = new Map()) {
120
+ const canonicalAssignments = assignments
121
+ .map((assignment) => canonicalizeTaskAssignmentFromSourceMessage(assignment, sourceMessagesById))
122
+ .filter((assignment) => assignment != null)
123
+ .sort(compareTaskAssignmentRecency);
124
+ const visibleAssignments = [];
125
+ const seenIssueNumbers = new Set();
126
+ for (const assignment of canonicalAssignments) {
127
+ if (seenIssueNumbers.has(assignment.issueNumber)) {
128
+ continue;
129
+ }
130
+ seenIssueNumbers.add(assignment.issueNumber);
131
+ visibleAssignments.push(assignment);
132
+ }
133
+ return visibleAssignments;
134
+ }
135
+ function normalizePullRequests(prs) {
136
+ if (!Array.isArray(prs)) {
137
+ return [];
138
+ }
139
+ return prs.filter((pr) => typeof pr?.number === "number" &&
140
+ typeof pr?.state === "string" &&
141
+ typeof pr?.headRefName === "string");
142
+ }
143
+ function chooseBestPullRequest(prs) {
144
+ if (prs.length === 0) {
145
+ return null;
146
+ }
147
+ const score = (pr) => {
148
+ if (pr.mergedAt)
149
+ return 3;
150
+ if (pr.state.toUpperCase() === "OPEN")
151
+ return 2;
152
+ return 1;
153
+ };
154
+ return ([...prs].sort((left, right) => {
155
+ const byScore = score(right) - score(left);
156
+ if (byScore !== 0)
157
+ return byScore;
158
+ const leftMergedAt = left.mergedAt ? Date.parse(left.mergedAt) : 0;
159
+ const rightMergedAt = right.mergedAt ? Date.parse(right.mergedAt) : 0;
160
+ if (rightMergedAt !== leftMergedAt)
161
+ return rightMergedAt - leftMergedAt;
162
+ return right.number - left.number;
163
+ })[0] ?? null);
164
+ }
165
+ async function resolveBaseRef(cwd, runner) {
166
+ for (const ref of ["origin/main", "main"]) {
167
+ const resolved = await runCommand("git", ["rev-parse", "--verify", "--quiet", ref], cwd, runner);
168
+ if (resolved) {
169
+ return ref;
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+ async function getBranchAheadCount(branch, baseRef, cwd, runner) {
175
+ if (!branch || !baseRef) {
176
+ return 0;
177
+ }
178
+ let maxAheadCount = 0;
179
+ const refs = [...new Set([branch, `origin/${branch}`])];
180
+ for (const ref of refs) {
181
+ const resolved = await runCommand("git", ["rev-parse", "--verify", "--quiet", ref], cwd, runner);
182
+ if (!resolved) {
183
+ continue;
184
+ }
185
+ const count = await runCommand("git", ["rev-list", "--count", `${baseRef}..${ref}`], cwd, runner);
186
+ const aheadCount = Number.parseInt(count ?? "0", 10);
187
+ if (Number.isFinite(aheadCount) && aheadCount > maxAheadCount) {
188
+ maxAheadCount = aheadCount;
189
+ }
190
+ }
191
+ return maxAheadCount;
192
+ }
193
+ async function getPullRequestForBranch(branch, cwd, runner) {
194
+ if (!branch) {
195
+ return null;
196
+ }
197
+ const rawPrs = await runJsonCommand("gh", [
198
+ "pr",
199
+ "list",
200
+ "--head",
201
+ branch,
202
+ "--state",
203
+ "all",
204
+ "--json",
205
+ "number,state,mergedAt,headRefName",
206
+ ], cwd, runner);
207
+ if (rawPrs === undefined) {
208
+ return undefined;
209
+ }
210
+ const prs = normalizePullRequests(rawPrs);
211
+ return chooseBestPullRequest(prs);
212
+ }
213
+ async function getPullRequestByNumber(prNumber, cwd, runner) {
214
+ const pr = await runJsonCommand("gh", ["pr", "view", String(prNumber), "--json", "number,state,mergedAt,headRefName"], cwd, runner);
215
+ if (pr === undefined) {
216
+ return undefined;
217
+ }
218
+ return normalizePullRequests([pr])[0] ?? null;
219
+ }
220
+ async function getIssueByNumber(issueNumber, cwd, runner) {
221
+ const issue = await runJsonCommand("gh", ["issue", "view", String(issueNumber), "--json", "number,state"], cwd, runner);
222
+ if (issue === undefined) {
223
+ return undefined;
224
+ }
225
+ if (issue && typeof issue.number === "number" && typeof issue.state === "string") {
226
+ return issue;
227
+ }
228
+ return null;
229
+ }
230
+ function normalizeIssueState(issue) {
231
+ const state = issue?.state?.toUpperCase();
232
+ if (state === "OPEN" || state === "CLOSED") {
233
+ return state;
234
+ }
235
+ return null;
236
+ }
237
+ function hasTrackedPullRequestLink(assignment, pr) {
238
+ return (assignment.prNumber === pr.number ||
239
+ assignment.status === "pr_open" ||
240
+ assignment.status === "pr_merged" ||
241
+ assignment.status === "pr_closed");
242
+ }
243
+ function resolveTaskStatus(assignment, branchAheadCount, pr) {
244
+ if (pr === undefined && assignment.status.startsWith("pr_")) {
245
+ return { nextStatus: assignment.status, nextPrNumber: assignment.prNumber };
246
+ }
247
+ if (pr?.state.toUpperCase() === "OPEN") {
248
+ return { nextStatus: "pr_open", nextPrNumber: pr.number };
249
+ }
250
+ if (pr?.mergedAt) {
251
+ if (hasTrackedPullRequestLink(assignment, pr)) {
252
+ return { nextStatus: "pr_merged", nextPrNumber: pr.number };
253
+ }
254
+ if (branchAheadCount > 0) {
255
+ return { nextStatus: "branch_pushed", nextPrNumber: null };
256
+ }
257
+ return { nextStatus: "assigned", nextPrNumber: null };
258
+ }
259
+ if (pr) {
260
+ if (hasTrackedPullRequestLink(assignment, pr)) {
261
+ return { nextStatus: "pr_closed", nextPrNumber: pr.number };
262
+ }
263
+ if (branchAheadCount > 0) {
264
+ return { nextStatus: "branch_pushed", nextPrNumber: null };
265
+ }
266
+ return { nextStatus: "assigned", nextPrNumber: null };
267
+ }
268
+ if (branchAheadCount > 0) {
269
+ return { nextStatus: "branch_pushed", nextPrNumber: null };
270
+ }
271
+ return { nextStatus: "assigned", nextPrNumber: null };
272
+ }
273
+ async function resolveTaskAssignments(assignments, cwd = process.cwd(), runner = execFileAsync) {
274
+ if (assignments.length === 0) {
275
+ return [];
276
+ }
277
+ const baseRef = await resolveBaseRef(cwd, runner);
278
+ const branchProgressCache = new Map();
279
+ const pullRequestByNumberCache = new Map();
280
+ const issueByNumberCache = new Map();
281
+ const resolveBranchProgress = (branch) => {
282
+ const cacheKey = branch ?? "";
283
+ const cached = branchProgressCache.get(cacheKey);
284
+ if (cached) {
285
+ return cached;
286
+ }
287
+ const promise = Promise.all([
288
+ getBranchAheadCount(branch, baseRef, cwd, runner),
289
+ getPullRequestForBranch(branch, cwd, runner),
290
+ ]).then(([branchAheadCount, pr]) => ({ branchAheadCount, pr }));
291
+ branchProgressCache.set(cacheKey, promise);
292
+ return promise;
293
+ };
294
+ const resolvePullRequestByNumber = (prNumber) => {
295
+ const cached = pullRequestByNumberCache.get(prNumber);
296
+ if (cached) {
297
+ return cached;
298
+ }
299
+ const promise = getPullRequestByNumber(prNumber, cwd, runner);
300
+ pullRequestByNumberCache.set(prNumber, promise);
301
+ return promise;
302
+ };
303
+ const resolveIssueByNumber = (issueNumber) => {
304
+ const cached = issueByNumberCache.get(issueNumber);
305
+ if (cached) {
306
+ return cached;
307
+ }
308
+ const promise = getIssueByNumber(issueNumber, cwd, runner);
309
+ issueByNumberCache.set(issueNumber, promise);
310
+ return promise;
311
+ };
312
+ return Promise.all(assignments.map(async (assignment) => {
313
+ const { branchAheadCount, pr } = await resolveBranchProgress(assignment.branch);
314
+ const resolvedPr = pr == null && assignment.prNumber != null
315
+ ? await resolvePullRequestByNumber(assignment.prNumber)
316
+ : pr;
317
+ const issueState = normalizeIssueState(await resolveIssueByNumber(assignment.issueNumber));
318
+ const { nextStatus, nextPrNumber } = resolveTaskStatus(assignment, branchAheadCount, resolvedPr);
319
+ return {
320
+ ...assignment,
321
+ nextStatus,
322
+ nextPrNumber,
323
+ branchAheadCount,
324
+ issueState,
325
+ };
326
+ }));
327
+ }
328
+ function hasTaskAssignmentStatusChange(assignment) {
329
+ return (assignment.status !== assignment.nextStatus || assignment.prNumber !== assignment.nextPrNumber);
330
+ }
331
+ function formatTaskProgressFragment(assignment) {
332
+ switch (assignment.status) {
333
+ case "pr_merged":
334
+ return `#${assignment.issueNumber} → PR #${assignment.prNumber ?? "?"} MERGED ✅`;
335
+ case "pr_open":
336
+ return `#${assignment.issueNumber} → PR #${assignment.prNumber ?? "?"} OPEN 🔄`;
337
+ case "pr_closed":
338
+ return `#${assignment.issueNumber} → PR #${assignment.prNumber ?? "?"} CLOSED ⚠️`;
339
+ case "branch_pushed":
340
+ return `#${assignment.issueNumber} → commits on ${assignment.branch ?? "tracked branch"}, no PR 👀`;
341
+ case "assigned":
342
+ default:
343
+ return `#${assignment.issueNumber} → no commits, no PR ⚠️`;
344
+ }
345
+ }
346
+ function getVisibleTaskAssignmentReportEntries(assignments) {
347
+ return assignments.filter((assignment) => assignment.issueState !== "CLOSED");
348
+ }
349
+ function formatAgentLabel(agentId, agentsById) {
350
+ const agent = agentsById.get(agentId);
351
+ if (!agent) {
352
+ return agentId;
353
+ }
354
+ return `${agent.emoji} ${agent.name}`.trim();
355
+ }
356
+ function getAgentSortKey(agentId, agentsById) {
357
+ const agent = agentsById.get(agentId);
358
+ return agent?.name ?? agentId;
359
+ }
360
+ function buildTaskAssignmentReport(assignments, agentsById, cycleStartedAt) {
361
+ const visibleAssignments = getVisibleTaskAssignmentReportEntries(assignments);
362
+ if (visibleAssignments.length === 0) {
363
+ return null;
364
+ }
365
+ const grouped = new Map();
366
+ for (const assignment of visibleAssignments) {
367
+ const bucket = grouped.get(assignment.agentId);
368
+ if (bucket) {
369
+ bucket.push(assignment);
370
+ }
371
+ else {
372
+ grouped.set(assignment.agentId, [assignment]);
373
+ }
374
+ }
375
+ const lines = [...grouped.entries()]
376
+ .sort(([leftAgentId], [rightAgentId]) => {
377
+ const leftLabel = getAgentSortKey(leftAgentId, agentsById);
378
+ const rightLabel = getAgentSortKey(rightAgentId, agentsById);
379
+ return leftLabel.localeCompare(rightLabel);
380
+ })
381
+ .map(([agentId, agentAssignments]) => {
382
+ const summary = [...agentAssignments]
383
+ .sort((left, right) => left.issueNumber - right.issueNumber)
384
+ .map((assignment) => formatTaskProgressFragment(assignment))
385
+ .join("; ");
386
+ return `- ${formatAgentLabel(agentId, agentsById)}: ${summary}`;
387
+ });
388
+ const header = cycleStartedAt
389
+ ? ["RALPH LOOP — WORKER STATUS:", `Timestamp: ${cycleStartedAt}`]
390
+ : ["RALPH LOOP — WORKER STATUS:"];
391
+ return [...header, ...lines].join("\n");
392
+ }
393
+ function getPendingTaskAssignmentReport(assignments, agentsById, lastDeliveredSignature, cycleStartedAt) {
394
+ const signature = buildTaskAssignmentReport(assignments, agentsById);
395
+ if (!signature || signature === lastDeliveredSignature) {
396
+ return null;
397
+ }
398
+ const message = buildTaskAssignmentReport(assignments, agentsById, cycleStartedAt);
399
+ if (!message) {
400
+ return null;
401
+ }
402
+ return { message, signature };
403
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TtlSet = exports.TtlCache = void 0;
4
+ /**
5
+ * Lightweight TTL + max-size cache.
6
+ *
7
+ * Entries expire after `ttlMs` milliseconds and are lazily evicted on
8
+ * access. When the cache exceeds `maxSize`, the oldest entry (by
9
+ * insertion / last-update order) is dropped — Map iteration order
10
+ * guarantees FIFO.
11
+ */
12
+ class TtlCache {
13
+ map = new Map();
14
+ maxSize;
15
+ ttlMs;
16
+ now;
17
+ constructor(options) {
18
+ this.maxSize = options.maxSize;
19
+ this.ttlMs = options.ttlMs;
20
+ this.now = options.now ?? (() => Date.now());
21
+ }
22
+ get(key) {
23
+ const entry = this.map.get(key);
24
+ if (!entry)
25
+ return undefined;
26
+ if (this.now() - entry.touchedAt > this.ttlMs) {
27
+ this.map.delete(key);
28
+ return undefined;
29
+ }
30
+ return entry.value;
31
+ }
32
+ set(key, value) {
33
+ // Delete first so re-insertion moves the key to the end (Map ordering).
34
+ this.map.delete(key);
35
+ this.map.set(key, { value, touchedAt: this.now() });
36
+ this.evictOverflow();
37
+ return this;
38
+ }
39
+ has(key) {
40
+ return this.get(key) !== undefined;
41
+ }
42
+ delete(key) {
43
+ return this.map.delete(key);
44
+ }
45
+ get size() {
46
+ return this.map.size;
47
+ }
48
+ /** Iterate live (non-expired) entries. */
49
+ *entries() {
50
+ const now = this.now();
51
+ for (const [key, entry] of this.map) {
52
+ if (now - entry.touchedAt <= this.ttlMs) {
53
+ yield [key, entry.value];
54
+ }
55
+ }
56
+ }
57
+ /** Sweep all expired entries in one pass. */
58
+ sweep() {
59
+ const now = this.now();
60
+ let swept = 0;
61
+ for (const [key, entry] of this.map) {
62
+ if (now - entry.touchedAt > this.ttlMs) {
63
+ this.map.delete(key);
64
+ swept++;
65
+ }
66
+ }
67
+ return swept;
68
+ }
69
+ clear() {
70
+ this.map.clear();
71
+ }
72
+ evictOverflow() {
73
+ while (this.map.size > this.maxSize) {
74
+ const oldest = this.map.keys().next();
75
+ if (oldest.done)
76
+ break;
77
+ this.map.delete(oldest.value);
78
+ }
79
+ }
80
+ }
81
+ exports.TtlCache = TtlCache;
82
+ /**
83
+ * TTL + max-size Set — thin wrapper around TtlCache<V, true>.
84
+ */
85
+ class TtlSet {
86
+ cache;
87
+ constructor(options) {
88
+ this.cache = new TtlCache(options);
89
+ }
90
+ add(value) {
91
+ this.cache.set(value, true);
92
+ return this;
93
+ }
94
+ has(value) {
95
+ return this.cache.has(value);
96
+ }
97
+ delete(value) {
98
+ return this.cache.delete(value);
99
+ }
100
+ get size() {
101
+ return this.cache.size;
102
+ }
103
+ sweep() {
104
+ return this.cache.sweep();
105
+ }
106
+ clear() {
107
+ this.cache.clear();
108
+ }
109
+ }
110
+ exports.TtlSet = TtlSet;