Add Hermes Paperclip task bridge

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-26 13:53:20 +00:00
1 parent 802245c94b
commit f565280dff
7 files changed
+676

No files matched your search

+33
View File
@@ -108,6 +108,39 @@ secret values, so do not copy printed tokens into comments or config. Use
multiline comments or status updates, preserve newlines with a heredoc plus
`jq --arg`.
### Hermes-originated Paperclip tasks
The package includes a Hermes skill/helper for the reverse direction: a user
starts in Hermes and asks Hermes to create or update Paperclip work. This is not
the same as Paperclip waking Hermes through `hermes_local` or `hermes_gateway`.
Configure Paperclip access in Hermes env/profile secrets, not prompt text:
```bash
PAPERCLIP_API_URL=http://127.0.0.1:3100/api
PAPERCLIP_API_KEY=<scoped-agent-api-key>
```
Optional env values:
- `PAPERCLIP_COMPANY_ID`
- `PAPERCLIP_AGENT_ID`
- `PAPERCLIP_RUN_ID`
The bundled `paperclip-task-bridge` skill provides deterministic helper
commands:
```bash
node ./paperclip-task.mjs list-assigned
node ./paperclip-task.mjs create-task --title "Investigate checkout failures" --description "Capture failing request and root cause."
node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path."
node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review."
```
The helper reads credentials from environment variables and prints only JSON
summaries. It supports `create-task`, `comment`, `update-status`, and
`list-assigned`.
### 3. Assign work
Create issues in Paperclip and assign them to your Hermes agent. On each heartbeat, Hermes will:
@@ -59,6 +59,7 @@
},
"files": [
"dist",
"skills",
"ui-parser.cjs",
"README.md",
"LICENSE"
@@ -0,0 +1,57 @@
---
name: paperclip-task-bridge
description: Create, comment on, update, and list Paperclip tasks from Hermes using scoped Paperclip API credentials.
---
# Paperclip Task Bridge
Use this skill when a Hermes-originated request needs to create or update Paperclip work directly. This is the Hermes-to-Paperclip direction, separate from Paperclip waking Hermes through the `hermes_local` or `hermes_gateway` adapter.
## Required Environment
Configure these in Hermes env/profile secrets, not in prompt text:
- `PAPERCLIP_API_URL` - Paperclip base URL, with or without `/api`.
- `PAPERCLIP_API_KEY` - scoped Paperclip agent API key for the Hermes actor.
Optional:
- `PAPERCLIP_COMPANY_ID` - skips one identity lookup when set.
- `PAPERCLIP_AGENT_ID` - skips one identity lookup when set.
- `PAPERCLIP_RUN_ID` - sent as `X-Paperclip-Run-Id` on mutating requests when Hermes is running inside a Paperclip heartbeat.
Never print or paste API keys. The helper reads credentials from environment variables and only prints response summaries.
## Helper
Run the helper from this skill directory:
```sh
node ./paperclip-task.mjs --help
```
Commands:
```sh
node ./paperclip-task.mjs list-assigned
node ./paperclip-task.mjs create-task --title "Investigate checkout failures" --description "Capture failing request and root cause."
node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path."
node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review."
```
`create-task` defaults to assigning the task to the authenticated Hermes agent so the work is immediately actionable. Use `--unassigned` to create backlog work instead. Use `--assignee-agent-id <uuid>` only when the Paperclip API key has permission to assign work to that agent.
For multiline bodies, prefer files or stdin:
```sh
node ./paperclip-task.mjs create-task --title "Write rollout note" --description-file ./task.md
node ./paperclip-task.mjs comment --issue PAP-123 --body-file -
```
## Workflow Expectations
- Keep tasks company-scoped by using the company resolved from the scoped agent key.
- Let Paperclip activity logging come from the normal API endpoints; do not write local logs that include credentials.
- Use comments for durable progress.
- Use `update-status` only when the issue has a real disposition: `done`, `in_review`, `blocked`, `todo`, `in_progress`, `backlog`, or `cancelled`.
- Use `list-assigned` before creating duplicate work when the user asks about current Paperclip assignments.
@@ -0,0 +1,371 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
const STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
const PRIORITIES = new Set(["critical", "high", "medium", "low"]);
const WORK_MODES = new Set(["standard", "ask", "planning"]);
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const HELP = `Paperclip task bridge for Hermes
Usage:
paperclip-task.mjs list-assigned [--status todo,in_progress,in_review,blocked] [--limit 20]
paperclip-task.mjs create-task --title <title> [--description <text>|--description-file <path|->] [options]
paperclip-task.mjs comment --issue <id|identifier> (--body <text>|--body-file <path|->) [--resume|--reopen]
paperclip-task.mjs update-status --issue <id|identifier> --status <status> [--comment <text>|--comment-file <path|->]
Environment:
PAPERCLIP_API_URL Paperclip base URL, with or without /api.
PAPERCLIP_API_KEY Scoped Paperclip agent API key.
PAPERCLIP_COMPANY_ID Optional company id override.
PAPERCLIP_AGENT_ID Optional agent id override.
PAPERCLIP_RUN_ID Optional run id for X-Paperclip-Run-Id on mutations.
create-task options:
--assignee-agent-id <uuid|self> Assign to an agent. Defaults to self.
--unassigned Create backlog/unassigned work.
--parent-id <uuid> Parent issue id.
--goal-id <uuid> Goal id.
--project-id <uuid> Project id.
--priority <critical|high|medium|low>
--status <backlog|todo|in_progress|in_review|done|blocked|cancelled>
--work-mode <standard|ask|planning>
Output is JSON and never includes credentials.`;
class UsageError extends Error {
constructor(message) {
super(message);
this.name = "UsageError";
}
}
class ApiError extends Error {
constructor(status, body) {
const message = typeof body?.error === "string" ? body.error : `Paperclip API request failed with status ${status}`;
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
function parseArgs(argv) {
const out = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) {
out._.push(arg);
continue;
}
const eq = arg.indexOf("=");
if (eq !== -1) {
out[arg.slice(2, eq)] = arg.slice(eq + 1);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
out[key] = true;
continue;
}
out[key] = next;
i += 1;
}
return out;
}
function readStringFlag(args, name) {
const value = args[name];
if (typeof value !== "string" || value.trim().length === 0) return null;
return value;
}
function requireStringFlag(args, name) {
const value = readStringFlag(args, name);
if (!value) throw new UsageError(`Missing required --${name}`);
return value;
}
function boolFlag(args, name) {
return args[name] === true;
}
function normalizeApiBaseUrl(raw) {
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
throw new UsageError("PAPERCLIP_API_URL is required");
}
const trimmed = raw.trim().replace(/\/+$/, "");
return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`;
}
function getConfig() {
const apiKey = process.env.PAPERCLIP_API_KEY?.trim();
if (!apiKey) throw new UsageError("PAPERCLIP_API_KEY is required");
return {
apiBaseUrl: normalizeApiBaseUrl(process.env.PAPERCLIP_API_URL),
apiKey,
runId: process.env.PAPERCLIP_RUN_ID?.trim() || null,
companyId: process.env.PAPERCLIP_COMPANY_ID?.trim() || null,
agentId: process.env.PAPERCLIP_AGENT_ID?.trim() || null,
};
}
async function readBody(args, textFlag, fileFlag) {
const direct = readStringFlag(args, textFlag);
const file = readStringFlag(args, fileFlag);
if (direct && file) throw new UsageError(`Use either --${textFlag} or --${fileFlag}, not both`);
if (direct) return direct;
if (!file) return null;
if (file === "-") {
return await new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", reject);
});
}
return fs.readFile(file, "utf8");
}
async function apiFetch(config, path, options = {}) {
const headers = {
Authorization: `Bearer ${config.apiKey}`,
Accept: "application/json",
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
...(options.mutating && config.runId ? { "X-Paperclip-Run-Id": config.runId } : {}),
};
const response = await fetch(`${config.apiBaseUrl}${path}`, {
method: options.method ?? "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { error: text.slice(0, 1000) };
}
}
if (!response.ok) throw new ApiError(response.status, body);
return body;
}
async function resolveIdentity(config) {
if (config.companyId && config.agentId) {
return { companyId: config.companyId, agentId: config.agentId, agent: null };
}
const agent = await apiFetch(config, "/agents/me");
const companyId = config.companyId || agent.companyId;
const agentId = config.agentId || agent.id;
if (!companyId || !agentId) throw new ApiError(500, { error: "Paperclip identity response did not include companyId and agent id" });
return { companyId, agentId, agent };
}
function issueSummary(issue) {
if (!issue || typeof issue !== "object") return issue;
return {
id: issue.id ?? null,
identifier: issue.identifier ?? null,
title: issue.title ?? null,
status: issue.status ?? null,
priority: issue.priority ?? null,
assigneeAgentId: issue.assigneeAgentId ?? null,
assigneeUserId: issue.assigneeUserId ?? null,
projectId: issue.projectId ?? null,
goalId: issue.goalId ?? null,
parentId: issue.parentId ?? null,
updatedAt: issue.updatedAt ?? null,
};
}
function commentSummary(comment) {
if (!comment || typeof comment !== "object") return comment;
return {
id: comment.id ?? null,
issueId: comment.issueId ?? null,
authorType: comment.authorType ?? null,
authorAgentId: comment.authorAgentId ?? null,
createdAt: comment.createdAt ?? null,
};
}
function printJson(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function validateEnum(value, allowed, label) {
if (!allowed.has(value)) {
throw new UsageError(`Invalid ${label}: ${value}`);
}
return value;
}
function parseLimit(args) {
const raw = readStringFlag(args, "limit");
if (!raw) return 20;
const value = Number.parseInt(raw, 10);
if (!Number.isInteger(value) || value <= 0 || value > 100) {
throw new UsageError("--limit must be an integer from 1 to 100");
}
return value;
}
async function resolveIssueId(config, companyId, ref) {
if (UUID_RE.test(ref)) return ref;
const query = new URLSearchParams({
q: ref,
limit: "10",
});
const issues = await apiFetch(config, `/companies/${encodeURIComponent(companyId)}/issues?${query}`);
const match = Array.isArray(issues)
? issues.find((issue) => issue?.identifier === ref || issue?.id === ref)
: null;
if (!match?.id) throw new ApiError(404, { error: `Issue not found for reference ${ref}` });
return match.id;
}
async function listAssigned(config, args) {
const identity = await resolveIdentity(config);
const status = readStringFlag(args, "status") || "todo,in_progress,in_review,blocked";
const limit = parseLimit(args);
const query = new URLSearchParams({
assigneeAgentId: identity.agentId,
status,
limit: String(limit),
sortField: "updated",
sortDir: "desc",
includeBlockedBy: "true",
});
const issues = await apiFetch(config, `/companies/${encodeURIComponent(identity.companyId)}/issues?${query}`);
printJson({
command: "list-assigned",
companyId: identity.companyId,
agentId: identity.agentId,
count: Array.isArray(issues) ? issues.length : 0,
issues: Array.isArray(issues) ? issues.map(issueSummary) : [],
});
}
async function createTask(config, args) {
const identity = await resolveIdentity(config);
const title = requireStringFlag(args, "title");
const description = await readBody(args, "description", "description-file");
const unassigned = boolFlag(args, "unassigned");
const assigneeRaw = readStringFlag(args, "assignee-agent-id");
const assigneeAgentId = unassigned
? undefined
: !assigneeRaw || assigneeRaw === "self"
? identity.agentId
: assigneeRaw;
if (assigneeAgentId !== undefined && !UUID_RE.test(assigneeAgentId)) {
throw new UsageError("--assignee-agent-id must be a UUID, self, or omitted");
}
const priority = readStringFlag(args, "priority") ?? "medium";
const workMode = readStringFlag(args, "work-mode") ?? "standard";
validateEnum(priority, PRIORITIES, "priority");
validateEnum(workMode, WORK_MODES, "work mode");
const body = {
title,
description,
priority,
workMode,
...(assigneeAgentId !== undefined ? { assigneeAgentId } : {}),
};
for (const [flag, field] of [
["parent-id", "parentId"],
["goal-id", "goalId"],
["project-id", "projectId"],
]) {
const value = readStringFlag(args, flag);
if (value) body[field] = value;
}
const status = readStringFlag(args, "status");
if (status) body.status = validateEnum(status, STATUSES, "status");
const issue = await apiFetch(config, `/companies/${encodeURIComponent(identity.companyId)}/issues`, {
method: "POST",
mutating: true,
body,
});
printJson({ command: "create-task", issue: issueSummary(issue) });
}
async function comment(config, args) {
const identity = await resolveIdentity(config);
const issueRef = requireStringFlag(args, "issue");
const bodyText = await readBody(args, "body", "body-file");
if (!bodyText || bodyText.trim().length === 0) throw new UsageError("comment requires --body or --body-file");
const issueId = await resolveIssueId(config, identity.companyId, issueRef);
const commentBody = {
body: bodyText,
...(boolFlag(args, "resume") ? { resume: true } : {}),
...(boolFlag(args, "reopen") ? { reopen: true } : {}),
};
const created = await apiFetch(config, `/issues/${encodeURIComponent(issueId)}/comments`, {
method: "POST",
mutating: true,
body: commentBody,
});
printJson({ command: "comment", issueId, comment: commentSummary(created) });
}
async function updateStatus(config, args) {
const identity = await resolveIdentity(config);
const issueRef = requireStringFlag(args, "issue");
const status = validateEnum(requireStringFlag(args, "status"), STATUSES, "status");
const commentText = await readBody(args, "comment", "comment-file");
const issueId = await resolveIssueId(config, identity.companyId, issueRef);
const body = {
status,
...(commentText && commentText.trim().length > 0 ? { comment: commentText } : {}),
...(boolFlag(args, "resume") ? { resume: true } : {}),
...(boolFlag(args, "reopen") ? { reopen: true } : {}),
};
const issue = await apiFetch(config, `/issues/${encodeURIComponent(issueId)}`, {
method: "PATCH",
mutating: true,
body,
});
printJson({ command: "update-status", issue: issueSummary(issue) });
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const command = args._[0];
if (!command || command === "help" || command === "--help" || boolFlag(args, "help")) {
process.stdout.write(`${HELP}\n`);
return;
}
const config = getConfig();
if (command === "list-assigned") return listAssigned(config, args);
if (command === "create-task") return createTask(config, args);
if (command === "comment") return comment(config, args);
if (command === "update-status") return updateStatus(config, args);
throw new UsageError(`Unknown command: ${command}`);
}
main().catch((err) => {
if (err instanceof UsageError) {
process.stderr.write(`Usage error: ${err.message}\n\n${HELP}\n`);
process.exitCode = 2;
return;
}
if (err instanceof ApiError) {
printJson({
error: err.message,
status: err.status,
details: err.body?.details ?? null,
});
process.exitCode = 1;
return;
}
process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
});
@@ -21,3 +21,15 @@ test("root package export exposes Paperclip external adapter entrypoint", () =>
expect(typeof adapter.detectModel).toBe("function");
expect(typeof adapter.getConfigSchema).toBe("function");
});
test("Hermes adapter exposes bundled Paperclip task bridge skill", async () => {
const adapter = createServerAdapter();
const snapshot = await adapter.listSkills?.({
adapterType: "hermes_local",
agentId: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
config: {},
});
expect(snapshot?.entries.some((entry) => entry.runtimeName === "paperclip-task-bridge")).toBe(true);
});
@@ -106,6 +106,24 @@ tools, persistent memory, session persistence, skills, and MCP support.
| env | object | {} | Extra environment variables |
| promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders |
## Hermes-Originated Paperclip Tasks
This adapter package also ships a Hermes-facing Paperclip task bridge skill:
\`paperclip-task-bridge\`. Use it when a user starts in Hermes and asks Hermes
to create, comment on, update, or list Paperclip tasks.
Configure credentials through Hermes env/profile secrets, never in prompt text:
- \`PAPERCLIP_API_URL\` - Paperclip base URL, with or without \`/api\`
- \`PAPERCLIP_API_KEY\` - scoped Paperclip agent API key
- optional \`PAPERCLIP_COMPANY_ID\`, \`PAPERCLIP_AGENT_ID\`, and \`PAPERCLIP_RUN_ID\`
The bridge is separate from adapter execution:
- \`hermes_local\` means Paperclip shells out to local \`hermes chat\`.
- \`hermes_gateway\` means Paperclip wakes remote Hermes through Hermes's API server.
- \`paperclip-task-bridge\` means Hermes calls Paperclip's REST API to manage tasks.
## Available Template Variables
- \`{{agentId}}\` — Paperclip agent ID
@@ -0,0 +1,184 @@
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const helperPath = path.resolve(__dirname, "../../skills/paperclip-task-bridge/paperclip-task.mjs");
const apiKey = "pc_test_secret_should_not_print";
type RequestRecord = {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: unknown;
};
function runHelper(args: string[], env: Record<string, string>) {
return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => {
const child = spawn(process.execPath, [helperPath, ...args], {
env: {
...process.env,
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => resolve({ code, stdout, stderr }));
});
}
describe("paperclip-task-bridge helper", () => {
let server: http.Server;
let baseUrl: string;
let requests: RequestRecord[];
beforeEach(async () => {
requests = [];
server = http.createServer(async (req, res) => {
let raw = "";
req.setEncoding("utf8");
for await (const chunk of req) raw += chunk;
const body = raw ? JSON.parse(raw) : null;
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
headers: req.headers,
body,
});
res.setHeader("Content-Type", "application/json");
if (req.headers.authorization !== `Bearer ${apiKey}`) {
res.statusCode = 401;
res.end(JSON.stringify({ error: "bad auth" }));
return;
}
if (req.method === "GET" && req.url === "/api/agents/me") {
res.end(JSON.stringify({ id: "11111111-1111-4111-8111-111111111111", companyId: "22222222-2222-4222-8222-222222222222" }));
return;
}
if (req.method === "GET" && req.url?.startsWith("/api/companies/22222222-2222-4222-8222-222222222222/issues")) {
res.end(JSON.stringify([
{
id: "33333333-3333-4333-8333-333333333333",
identifier: "PAP-123",
title: "Existing task",
status: "todo",
priority: "medium",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
updatedAt: "2026-06-26T00:00:00.000Z",
},
]));
return;
}
if (req.method === "POST" && req.url === "/api/companies/22222222-2222-4222-8222-222222222222/issues") {
res.statusCode = 201;
res.end(JSON.stringify({
id: "44444444-4444-4444-8444-444444444444",
identifier: "PAP-124",
title: body.title,
status: body.status ?? "todo",
priority: body.priority,
assigneeAgentId: body.assigneeAgentId ?? null,
}));
return;
}
if (req.method === "POST" && req.url === "/api/issues/33333333-3333-4333-8333-333333333333/comments") {
res.statusCode = 201;
res.end(JSON.stringify({
id: "55555555-5555-4555-8555-555555555555",
issueId: "33333333-3333-4333-8333-333333333333",
authorType: "agent",
authorAgentId: "11111111-1111-4111-8111-111111111111",
createdAt: "2026-06-26T00:00:00.000Z",
}));
return;
}
if (req.method === "PATCH" && req.url === "/api/issues/33333333-3333-4333-8333-333333333333") {
res.end(JSON.stringify({
id: "33333333-3333-4333-8333-333333333333",
identifier: "PAP-123",
title: "Existing task",
status: body.status,
priority: "medium",
}));
return;
}
res.statusCode = 404;
res.end(JSON.stringify({ error: "not found" }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing server address");
baseUrl = `http://127.0.0.1:${address.port}/api`;
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
});
function env() {
return {
PAPERCLIP_API_URL: baseUrl,
PAPERCLIP_API_KEY: apiKey,
PAPERCLIP_COMPANY_ID: "22222222-2222-4222-8222-222222222222",
PAPERCLIP_AGENT_ID: "11111111-1111-4111-8111-111111111111",
PAPERCLIP_RUN_ID: "66666666-6666-4666-8666-666666666666",
};
}
it("lists assigned tasks without printing credentials", async () => {
const result = await runHelper(["list-assigned"], env());
expect(result.code).toBe(0);
expect(result.stdout).toContain('"command": "list-assigned"');
expect(result.stdout).toContain('"identifier": "PAP-123"');
expect(result.stdout).not.toContain(apiKey);
expect(result.stderr).not.toContain(apiKey);
expect(requests.some((request) => request.url.includes("assigneeAgentId=11111111-1111-4111-8111-111111111111"))).toBe(true);
});
it("creates tasks assigned to the authenticated agent by default", async () => {
const result = await runHelper(["create-task", "--title", "Bridge task", "--description", "Created from Hermes"], env());
expect(result.code).toBe(0);
expect(result.stdout).toContain('"identifier": "PAP-124"');
const createRequest = requests.find((request) => request.method === "POST" && request.url.includes("/companies/"));
expect(createRequest?.headers["x-paperclip-run-id"]).toBe("66666666-6666-4666-8666-666666666666");
expect(createRequest?.body).toMatchObject({
title: "Bridge task",
description: "Created from Hermes",
priority: "medium",
workMode: "standard",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
});
expect(result.stdout).not.toContain(apiKey);
});
it("comments and updates status by resolving issue identifiers", async () => {
const comment = await runHelper(["comment", "--issue", "PAP-123", "--body", "Progress from Hermes"], env());
const update = await runHelper(["update-status", "--issue", "PAP-123", "--status", "in_review", "--comment", "Ready"], env());
expect(comment.code).toBe(0);
expect(update.code).toBe(0);
const commentRequest = requests.find((request) => request.method === "POST" && request.url.includes("/comments"));
const patchRequest = requests.find((request) => request.method === "PATCH");
expect(commentRequest?.body).toMatchObject({ body: "Progress from Hermes" });
expect(patchRequest?.body).toMatchObject({ status: "in_review", comment: "Ready" });
expect(comment.stdout + update.stdout).not.toContain(apiKey);
});
});