patchrelay 0.36.6 → 0.36.8

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/README.md CHANGED
@@ -161,6 +161,8 @@ PatchRelay uses repo-local workflow files as prompts for Codex runs:
161
161
 
162
162
  These files define how the agent should work in that repository. Keep them short, action-oriented, and human-authored.
163
163
 
164
+ The built-in PatchRelay prompt scaffold lives in `src/prompting/patchrelay.ts`. It is intentionally small: task objective, scope discipline, reactive repair context, workflow guidance, and publication contract. Installation-level and repo-level prompt config can add one extra instructions file or replace a small set of policy sections. See [`docs/prompting.md`](./docs/prompting.md).
165
+
164
166
  ## Knowledge And Validation Surfaces
165
167
 
166
168
  PatchRelay works best when repository guidance follows progressive disclosure:
@@ -306,6 +308,7 @@ Useful commands:
306
308
  - `patchrelay issue path APP-123 --cd`
307
309
  - `patchrelay issue open APP-123`
308
310
  - `patchrelay issue retry APP-123`
311
+ - `patchrelay service restart`
309
312
  - `patchrelay service logs --lines 100`
310
313
 
311
314
  PatchRelay's operator surface is being reduced to its own runtime responsibilities: issue status,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.36.6",
4
- "commit": "b2c4dcb9b2f3",
5
- "builtAt": "2026-04-09T09:04:30.836Z"
3
+ "version": "0.36.8",
4
+ "commit": "a62440fe88ab",
5
+ "builtAt": "2026-04-09T12:45:27.217Z"
6
6
  }
@@ -31,7 +31,7 @@ export async function collectClusterHealth(config, db, runCommand) {
31
31
  });
32
32
  return {
33
33
  issue,
34
- session: db.getIssueSession(issue.projectId, issue.linearIssueId),
34
+ session: db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId),
35
35
  blockedBy,
36
36
  missingTrackedBlockers,
37
37
  ageMs: Math.max(0, now - Date.parse(issue.updatedAt || new Date(0).toISOString())),
@@ -44,13 +44,13 @@ export async function collectClusterHealth(config, db, runCommand) {
44
44
  ? await probeOptionalService(runCommand, "review-quill", {
45
45
  healthy: (payload) => {
46
46
  const parsed = payload;
47
- return parsed.health?.ok === true && parsed.systemd?.ActiveState === "active";
47
+ return parsed.health?.reachable === true && parsed.health?.ok === true && parsed.systemd?.ActiveState === "active";
48
48
  },
49
49
  summarize: (payload) => {
50
50
  const parsed = payload;
51
- return parsed.health?.ok === true
52
- ? `Healthy (${typeof parsed.watch?.runningAttempts === "number" ? `${parsed.watch.runningAttempts} running attempts` : "service reachable"})`
53
- : `Unhealthy (${parsed.healthError ?? "service health unavailable"})`;
51
+ return parsed.health?.reachable === true && parsed.health?.ok === true
52
+ ? "Healthy"
53
+ : `Unhealthy (${parsed.health?.reachable === false ? "service not reachable" : "service health unavailable"})`;
54
54
  },
55
55
  })
56
56
  : undefined;
@@ -68,13 +68,13 @@ export async function collectClusterHealth(config, db, runCommand) {
68
68
  ? await probeOptionalService(runCommand, "merge-steward", {
69
69
  healthy: (payload) => {
70
70
  const parsed = payload;
71
- return parsed.systemd?.ActiveState === "active";
71
+ return parsed.health?.reachable === true && parsed.health?.ok === true && parsed.systemd?.ActiveState === "active";
72
72
  },
73
73
  summarize: (payload) => {
74
74
  const parsed = payload;
75
- return parsed.systemd?.ActiveState === "active"
75
+ return parsed.health?.reachable === true && parsed.health?.ok === true && parsed.systemd?.ActiveState === "active"
76
76
  ? "Healthy"
77
- : `Unhealthy (${parsed.systemd?.ActiveState ?? "unknown"})`;
77
+ : `Unhealthy (${parsed.health?.reachable === false ? "service not reachable" : parsed.systemd?.ActiveState ?? "unknown"})`;
78
78
  },
79
79
  })
80
80
  : undefined;
@@ -1,11 +1,11 @@
1
- import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdUnitPath, } from "../../runtime-paths.js";
1
+ import { getSystemdUnitPath, } from "../../runtime-paths.js";
2
2
  import { initializePatchRelayHome, installServiceUnits } from "../../install.js";
3
3
  import { loadConfig } from "../../config.js";
4
4
  import { parsePositiveIntegerFlag } from "../args.js";
5
5
  import { CliUsageError } from "../errors.js";
6
6
  import { formatJson } from "../formatters/json.js";
7
7
  import { writeOutput } from "../output.js";
8
- import { installServiceCommands, restartServiceCommands, runServiceCommands, tryManageService } from "../service-commands.js";
8
+ import { installServiceCommands, runServiceCommands, runSystemctl, tryManageService } from "../service-commands.js";
9
9
  export async function handleInitCommand(params) {
10
10
  try {
11
11
  const requestedPublicBaseUrl = typeof params.parsed.flags.get("public-base-url") === "string"
@@ -113,24 +113,25 @@ export async function handleInstallServiceCommand(params) {
113
113
  }
114
114
  }
115
115
  export async function handleRestartServiceCommand(params) {
116
- try {
117
- await runServiceCommands(params.runCommand, restartServiceCommands());
118
- writeOutput(params.stdout, params.json
119
- ? formatJson({
120
- service: "patchrelay",
121
- unitPath: getSystemdUnitPath(),
122
- runtimeEnvPath: getDefaultRuntimeEnvPath(),
123
- serviceEnvPath: getDefaultServiceEnvPath(),
124
- configPath: getDefaultConfigPath(),
125
- restarted: true,
126
- })
127
- : "Reloaded systemd units and reload-or-restart was requested for PatchRelay.\n");
128
- return 0;
129
- }
130
- catch (error) {
131
- writeOutput(params.stderr, `${error instanceof Error ? error.message : String(error)}\n`);
132
- return 1;
133
- }
116
+ const daemonReload = await runSystemctl(params.runCommand, ["daemon-reload"]);
117
+ const restart = await runSystemctl(params.runCommand, ["reload-or-restart", "patchrelay.service"]);
118
+ const ok = daemonReload.ok && restart.ok;
119
+ writeOutput(ok ? params.stdout : params.stderr, params.json
120
+ ? formatJson({
121
+ service: "patchrelay",
122
+ unit: "patchrelay.service",
123
+ daemonReloaded: daemonReload.ok,
124
+ restarted: restart.ok,
125
+ errors: [
126
+ ...(daemonReload.ok ? [] : [daemonReload.error]),
127
+ ...(restart.ok ? [] : [restart.error]),
128
+ ],
129
+ })
130
+ : [
131
+ daemonReload.ok ? "systemd daemon-reload completed." : `systemd daemon-reload failed: ${daemonReload.error}`,
132
+ restart.ok ? "Restarted patchrelay.service" : `Restart failed: ${restart.error}`,
133
+ ].join("\n") + "\n");
134
+ return ok ? 0 : 1;
134
135
  }
135
136
  function parseSystemctlShowOutput(raw) {
136
137
  const properties = {};
@@ -148,23 +149,28 @@ function parseSystemctlShowOutput(raw) {
148
149
  async function readPatchRelayHealth() {
149
150
  try {
150
151
  const config = loadConfig(undefined, { profile: "doctor" });
151
- const response = await fetch(`http://${config.server.bind}:${config.server.port}${config.server.healthPath}`, { signal: AbortSignal.timeout(2000) });
152
- let body;
152
+ const host = config.server.bind === "0.0.0.0" ? "127.0.0.1" : config.server.bind;
153
+ const response = await fetch(`http://${host}:${config.server.port}${config.server.healthPath}`, { signal: AbortSignal.timeout(2000) });
154
+ let ok = response.ok;
153
155
  try {
154
- body = await response.json();
156
+ const body = await response.json();
157
+ if (typeof body.ok === "boolean") {
158
+ ok = response.ok && body.ok;
159
+ }
155
160
  }
156
161
  catch {
157
- body = undefined;
162
+ ok = response.ok;
158
163
  }
159
164
  return {
160
165
  reachable: true,
166
+ ok,
161
167
  status: response.status,
162
- ...(body ? { body } : {}),
163
168
  };
164
169
  }
165
170
  catch (error) {
166
171
  return {
167
172
  reachable: false,
173
+ ok: false,
168
174
  error: error instanceof Error ? error.message : String(error),
169
175
  };
170
176
  }
@@ -201,7 +207,6 @@ export async function handleServiceCommand(params) {
201
207
  const payload = {
202
208
  service: "patchrelay",
203
209
  unit: "patchrelay.service",
204
- unitPath: getSystemdUnitPath(),
205
210
  systemd: properties,
206
211
  health,
207
212
  };
@@ -217,7 +222,7 @@ export async function handleServiceCommand(params) {
217
222
  `Unit path: ${properties.FragmentPath || getSystemdUnitPath()}`,
218
223
  properties.ExecMainPID ? `Main PID: ${properties.ExecMainPID}` : undefined,
219
224
  health.reachable
220
- ? `Health: reachable (HTTP ${health.status})${typeof health.body?.version === "string" ? ` version ${health.body.version}` : ""}`
225
+ ? `Health: ${health.ok ? "ok" : "unhealthy"} (HTTP ${health.status})`
221
226
  : `Health: not reachable (${health.error})`,
222
227
  ]
223
228
  .filter(Boolean)
package/dist/cli/data.js CHANGED
@@ -42,7 +42,7 @@ function summarizeThread(thread, latestTimestampSeen) {
42
42
  };
43
43
  }
44
44
  function latestEventTimestamp(db, runId) {
45
- const events = db.listThreadEvents(runId);
45
+ const events = db.runs.listThreadEvents(runId);
46
46
  return events.at(-1)?.createdAt;
47
47
  }
48
48
  function parseObjectJson(value) {
@@ -95,8 +95,8 @@ export class CliDataAccess extends CliOperatorApiClient {
95
95
  if (!issue)
96
96
  return undefined;
97
97
  const dbIssue = this.db.getIssueByKey(issueKey);
98
- const activeRun = dbIssue.activeRunId ? this.db.getRun(dbIssue.activeRunId) : undefined;
99
- const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
98
+ const activeRun = dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined;
99
+ const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
100
100
  const latestReport = normalizeStageReport(latestRun?.reportJson, latestRun?.status);
101
101
  const latestSummary = safeJsonParse(latestRun?.summaryJson);
102
102
  const statusNote = latestReport?.assistantMessages.at(-1) ??
@@ -121,7 +121,7 @@ export class CliDataAccess extends CliOperatorApiClient {
121
121
  if (!issue)
122
122
  return undefined;
123
123
  const dbIssue = this.db.getIssueByKey(issueKey);
124
- const run = dbIssue.activeRunId ? this.db.getRun(dbIssue.activeRunId) : undefined;
124
+ const run = dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined;
125
125
  if (!run)
126
126
  return undefined;
127
127
  const live = run.threadId &&
@@ -180,7 +180,7 @@ export class CliDataAccess extends CliOperatorApiClient {
180
180
  if (!issue)
181
181
  return undefined;
182
182
  const dbIssue = this.db.getIssueByKey(issueKey);
183
- const issueSession = this.db.getIssueSession(issue.projectId, issue.linearIssueId);
183
+ const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
184
184
  if (dbIssue.activeRunId !== undefined) {
185
185
  throw new Error(`Issue ${issueKey} already has an active run.`);
186
186
  }
@@ -215,7 +215,7 @@ export class CliDataAccess extends CliOperatorApiClient {
215
215
  if (!issue)
216
216
  return undefined;
217
217
  const dbIssue = this.db.getIssueByKey(issueKey);
218
- const runs = this.db.listRunsForIssue(issue.projectId, issue.linearIssueId);
218
+ const runs = this.db.runs.listRunsForIssue(issue.projectId, issue.linearIssueId);
219
219
  const sessions = runs
220
220
  .slice()
221
221
  .reverse()
@@ -230,7 +230,7 @@ export class CliDataAccess extends CliOperatorApiClient {
230
230
  ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
231
231
  ...(summary ? { summary } : {}),
232
232
  ...(run.failureReason ? { failureReason: run.failureReason } : {}),
233
- eventCount: this.db.listThreadEvents(run.id).length,
233
+ eventCount: this.db.runs.listThreadEvents(run.id).length,
234
234
  startedAt: run.startedAt,
235
235
  ...(run.endedAt ? { endedAt: run.endedAt } : {}),
236
236
  isCurrentThread: run.threadId !== undefined && run.threadId === dbIssue.threadId,
@@ -247,7 +247,7 @@ export class CliDataAccess extends CliOperatorApiClient {
247
247
  if (runType === "queue_repair") {
248
248
  const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
249
249
  const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
250
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
250
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
251
251
  projectId: issue.projectId,
252
252
  linearIssueId: issue.linearIssueId,
253
253
  eventType: "merge_steward_incident",
@@ -262,7 +262,7 @@ export class CliDataAccess extends CliOperatorApiClient {
262
262
  }
263
263
  if (runType === "ci_repair") {
264
264
  const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
265
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
265
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
266
266
  projectId: issue.projectId,
267
267
  linearIssueId: issue.linearIssueId,
268
268
  eventType: "settled_red_ci",
@@ -275,7 +275,7 @@ export class CliDataAccess extends CliOperatorApiClient {
275
275
  return;
276
276
  }
277
277
  if (runType === "review_fix" || runType === "branch_upkeep") {
278
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
278
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
279
279
  projectId: issue.projectId,
280
280
  linearIssueId: issue.linearIssueId,
281
281
  eventType: "review_changes_requested",
@@ -290,7 +290,7 @@ export class CliDataAccess extends CliOperatorApiClient {
290
290
  });
291
291
  return;
292
292
  }
293
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
293
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
294
294
  projectId: issue.projectId,
295
295
  linearIssueId: issue.linearIssueId,
296
296
  eventType: "delegated",
package/dist/cli/help.js CHANGED
@@ -165,7 +165,7 @@ export function serviceHelpText() {
165
165
  "Commands:",
166
166
  " install [--force] [--write-only] [--json] Reinstall the systemd service unit",
167
167
  " restart [--json] Reload-or-restart the service",
168
- " status [--json] Show systemd state and service health",
168
+ " status [--json] Show systemd state and local health",
169
169
  " logs [--lines <count>] [--json] Show recent journal logs",
170
170
  "",
171
171
  "Examples:",
@@ -23,6 +23,17 @@ export async function tryManageService(runner, commands) {
23
23
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
24
24
  }
25
25
  }
26
+ export async function runSystemctl(runner, args) {
27
+ const result = await runner("sudo", ["systemctl", ...args]);
28
+ if (result.exitCode === 0) {
29
+ return { ok: true, result };
30
+ }
31
+ return {
32
+ ok: false,
33
+ error: `Command failed with exit code ${result.exitCode}: sudo systemctl ${args.join(" ")}${summarizeCommandOutput(result)}`,
34
+ result,
35
+ };
36
+ }
26
37
  export function installServiceCommands() {
27
38
  return [
28
39
  { command: "sudo", args: ["systemctl", "daemon-reload"] },
package/dist/config.js CHANGED
@@ -59,6 +59,17 @@ const repositorySchema = z.object({
59
59
  merge_queue_check_name: z.string().min(1).optional(),
60
60
  }).optional(),
61
61
  });
62
+ const promptLayerSchema = z.object({
63
+ extra_instructions_file: z.string().min(1).optional(),
64
+ replace_sections: z.record(z.string().min(1), z.string().min(1)).default({}),
65
+ });
66
+ const promptByRunTypeSchema = z.object({
67
+ implementation: promptLayerSchema.optional(),
68
+ review_fix: promptLayerSchema.optional(),
69
+ branch_upkeep: promptLayerSchema.optional(),
70
+ ci_repair: promptLayerSchema.optional(),
71
+ queue_repair: promptLayerSchema.optional(),
72
+ });
62
73
  const configSchema = z.object({
63
74
  server: z.object({
64
75
  bind: z.string().default("127.0.0.1"),
@@ -121,6 +132,17 @@ const configSchema = z.object({
121
132
  experimental_raw_events: z.boolean().default(true),
122
133
  }),
123
134
  }),
135
+ prompting: z.object({
136
+ default: promptLayerSchema.default({
137
+ replace_sections: {},
138
+ }),
139
+ by_run_type: promptByRunTypeSchema.default({}),
140
+ }).default({
141
+ default: {
142
+ replace_sections: {},
143
+ },
144
+ by_run_type: {},
145
+ }),
124
146
  repos: z.object({
125
147
  root: z.string().min(1).default(path.join(homedir(), "projects")),
126
148
  }).default(() => ({ root: path.join(homedir(), "projects") })),
@@ -164,6 +186,7 @@ function withSectionDefaults(input) {
164
186
  logging: {},
165
187
  database: {},
166
188
  operator_api: {},
189
+ prompting: {},
167
190
  repos: {},
168
191
  projects: [],
169
192
  repositories: [],
@@ -181,6 +204,24 @@ function withSectionDefaults(input) {
181
204
  function resolveRepoSettingsPath(repoPath) {
182
205
  return path.join(repoPath, REPO_SETTINGS_DIRNAME, REPO_SETTINGS_FILENAME);
183
206
  }
207
+ function readPromptFile(configDir, filePath) {
208
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath);
209
+ if (!existsSync(resolvedPath)) {
210
+ throw new Error(`Prompt file not found: ${resolvedPath}`);
211
+ }
212
+ return {
213
+ sourcePath: resolvedPath,
214
+ content: readFileSync(resolvedPath, "utf8").trim(),
215
+ };
216
+ }
217
+ function loadPromptLayer(configDir, layer) {
218
+ return {
219
+ ...(layer.extra_instructions_file
220
+ ? { extraInstructions: readPromptFile(configDir, layer.extra_instructions_file) }
221
+ : {}),
222
+ replaceSections: Object.fromEntries(Object.entries(layer.replace_sections).map(([sectionId, fragmentPath]) => [sectionId, readPromptFile(configDir, fragmentPath)])),
223
+ };
224
+ }
184
225
  function expandEnv(value, env) {
185
226
  if (typeof value === "string") {
186
227
  return value.replace(/\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, (_match, name, fallback) => {
@@ -322,6 +363,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
322
363
  };
323
364
  const parsedFile = parseJsonFile(requestedPath, "config file");
324
365
  const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
366
+ const configDir = path.dirname(requestedPath);
325
367
  const requirements = getLoadProfileRequirements(profile);
326
368
  const rWebhookSecret = resolveSecretWithSource("linear-webhook-secret", parsed.linear.webhook_secret_env, env);
327
369
  const rTokenEncryptionKey = resolveSecretWithSource("token-encryption-key", parsed.linear.token_encryption_key_env, env);
@@ -487,6 +529,12 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
487
529
  experimentalRawEvents: parsed.runner.codex.experimental_raw_events,
488
530
  },
489
531
  },
532
+ prompting: {
533
+ default: loadPromptLayer(configDir, parsed.prompting.default),
534
+ byRunType: Object.fromEntries(Object.entries(parsed.prompting.by_run_type)
535
+ .filter(([, layer]) => Boolean(layer))
536
+ .map(([runType, layer]) => [runType, loadPromptLayer(configDir, layer)])),
537
+ },
490
538
  repos: {
491
539
  root: reposRoot,
492
540
  },