patchrelay 0.36.5 → 0.36.7

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.5",
4
- "commit": "1f30d4407cf6",
5
- "builtAt": "2026-04-09T08:27:55.606Z"
3
+ "version": "0.36.7",
4
+ "commit": "93dd74dadf6f",
5
+ "builtAt": "2026-04-09T11:23:49.821Z"
6
6
  }
@@ -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/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
  },
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ const promptLayerSchema = z.object({
5
+ extraInstructionsFile: z.string().min(1).optional(),
6
+ replaceSections: z.record(z.string().min(1), z.string().min(1)).default({}),
7
+ });
8
+ const promptByRunTypeSchema = z.object({
9
+ implementation: promptLayerSchema.optional(),
10
+ review_fix: promptLayerSchema.optional(),
11
+ branch_upkeep: promptLayerSchema.optional(),
12
+ ci_repair: promptLayerSchema.optional(),
13
+ queue_repair: promptLayerSchema.optional(),
14
+ });
15
+ const patchRelayCustomizationSchema = z.object({
16
+ version: z.literal(1),
17
+ prompt: z.object({
18
+ default: promptLayerSchema.default({
19
+ replaceSections: {},
20
+ }),
21
+ byRunType: promptByRunTypeSchema.default({}),
22
+ }).default({
23
+ default: {
24
+ replaceSections: {},
25
+ },
26
+ byRunType: {},
27
+ }),
28
+ });
29
+ function configPath(repoRoot) {
30
+ return path.join(repoRoot, ".patchrelay", "patchrelay.json");
31
+ }
32
+ function readPromptFile(repoRoot, filePath) {
33
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(repoRoot, filePath);
34
+ if (!existsSync(resolvedPath)) {
35
+ throw new Error(`Prompt file not found: ${resolvedPath}`);
36
+ }
37
+ return {
38
+ sourcePath: resolvedPath,
39
+ content: readFileSync(resolvedPath, "utf8").trim(),
40
+ };
41
+ }
42
+ function loadPromptLayer(repoRoot, layer) {
43
+ return {
44
+ ...(layer?.extraInstructionsFile
45
+ ? { extraInstructions: readPromptFile(repoRoot, layer.extraInstructionsFile) }
46
+ : {}),
47
+ replaceSections: Object.fromEntries(Object.entries(layer?.replaceSections ?? {})
48
+ .map(([sectionId, fragmentPath]) => [sectionId, readPromptFile(repoRoot, fragmentPath)])),
49
+ };
50
+ }
51
+ export function loadPatchRelayRepoPrompting(params) {
52
+ try {
53
+ const filePath = configPath(params.repoRoot);
54
+ if (!existsSync(filePath)) {
55
+ return undefined;
56
+ }
57
+ const raw = readFileSync(filePath, "utf8");
58
+ const parsed = patchRelayCustomizationSchema.parse(JSON.parse(raw));
59
+ return {
60
+ default: loadPromptLayer(params.repoRoot, parsed.prompt.default),
61
+ byRunType: Object.fromEntries(Object.entries(parsed.prompt.byRunType).map(([runType, layer]) => [runType, loadPromptLayer(params.repoRoot, layer)])),
62
+ };
63
+ }
64
+ catch (error) {
65
+ params.logger.warn({ error: error instanceof Error ? error.message : String(error), repoRoot: params.repoRoot }, "PatchRelay repo prompt customization could not be loaded");
66
+ return undefined;
67
+ }
68
+ }