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 +3 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/cluster-health.js +7 -7
- package/dist/cli/commands/setup.js +32 -27
- package/dist/cli/help.js +1 -1
- package/dist/cli/service-commands.js +11 -0
- package/dist/config.js +48 -0
- package/dist/patchrelay-customization.js +68 -0
- package/dist/prompting/patchrelay.js +552 -0
- package/dist/run-orchestrator.js +28 -560
- package/package.json +1 -1
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const WORKFLOW_FILES = {
|
|
4
|
+
implementation: "IMPLEMENTATION_WORKFLOW.md",
|
|
5
|
+
review_fix: "REVIEW_WORKFLOW.md",
|
|
6
|
+
branch_upkeep: "REVIEW_WORKFLOW.md",
|
|
7
|
+
ci_repair: "IMPLEMENTATION_WORKFLOW.md",
|
|
8
|
+
queue_repair: "IMPLEMENTATION_WORKFLOW.md",
|
|
9
|
+
};
|
|
10
|
+
export const PATCHRELAY_PROMPT_SECTION_IDS = [
|
|
11
|
+
"header",
|
|
12
|
+
"follow-up-turn",
|
|
13
|
+
"task-objective",
|
|
14
|
+
"scope-discipline",
|
|
15
|
+
"human-context",
|
|
16
|
+
"reactive-context",
|
|
17
|
+
"workflow-guidance",
|
|
18
|
+
"publication-contract",
|
|
19
|
+
];
|
|
20
|
+
export const PATCHRELAY_REPLACEABLE_SECTION_IDS = [
|
|
21
|
+
"scope-discipline",
|
|
22
|
+
"workflow-guidance",
|
|
23
|
+
"publication-contract",
|
|
24
|
+
];
|
|
25
|
+
function readWorkflowFile(repoPath, runType) {
|
|
26
|
+
const filename = WORKFLOW_FILES[runType];
|
|
27
|
+
const filePath = path.join(repoPath, filename);
|
|
28
|
+
if (!existsSync(filePath))
|
|
29
|
+
return undefined;
|
|
30
|
+
return readFileSync(filePath, "utf8").trim();
|
|
31
|
+
}
|
|
32
|
+
function collectImplementationInstructionText(issue, context, promptText) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (issue.title)
|
|
35
|
+
parts.push(issue.title);
|
|
36
|
+
if (issue.description)
|
|
37
|
+
parts.push(issue.description);
|
|
38
|
+
if (promptText)
|
|
39
|
+
parts.push(promptText);
|
|
40
|
+
const stringFields = ["promptContext", "promptBody", "operatorPrompt", "userComment"];
|
|
41
|
+
for (const field of stringFields) {
|
|
42
|
+
const value = context?.[field];
|
|
43
|
+
if (typeof value === "string" && value.trim()) {
|
|
44
|
+
parts.push(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(context?.followUps)) {
|
|
48
|
+
for (const entry of context.followUps) {
|
|
49
|
+
if (!entry || typeof entry !== "object")
|
|
50
|
+
continue;
|
|
51
|
+
const text = entry.text;
|
|
52
|
+
if (typeof text === "string" && text.trim()) {
|
|
53
|
+
parts.push(text);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return parts.join("\n").toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
export function resolveImplementationDeliveryMode(issue, context, promptText) {
|
|
60
|
+
const instructionText = collectImplementationInstructionText(issue, context, promptText);
|
|
61
|
+
if (!instructionText)
|
|
62
|
+
return "publish_pr";
|
|
63
|
+
const hasExplicitNoPr = [
|
|
64
|
+
/\bdo not open (?:a |any )?pr\b/,
|
|
65
|
+
/\bdo not open (?:a |any )?pull request\b/,
|
|
66
|
+
/\bno pr is opened\b/,
|
|
67
|
+
/\bpatchrelay should not open a pr\b/,
|
|
68
|
+
/\bwithout opening a pr\b/,
|
|
69
|
+
].some((pattern) => pattern.test(instructionText));
|
|
70
|
+
const forbidsRepoChanges = [
|
|
71
|
+
/\bdo not make repository changes\b/,
|
|
72
|
+
/\bdo not make repo changes\b/,
|
|
73
|
+
/\bno repository changes\b/,
|
|
74
|
+
/\bno repo changes\b/,
|
|
75
|
+
/\bdo not modify repo files\b/,
|
|
76
|
+
].some((pattern) => pattern.test(instructionText));
|
|
77
|
+
const planningOnly = [
|
|
78
|
+
/\bplanning\/specification issue only\b/,
|
|
79
|
+
/\bplanning[- ]only\b/,
|
|
80
|
+
/\bspecification[- ]only\b/,
|
|
81
|
+
/\bplanning issue only\b/,
|
|
82
|
+
].some((pattern) => pattern.test(instructionText));
|
|
83
|
+
if (hasExplicitNoPr || (planningOnly && forbidsRepoChanges)) {
|
|
84
|
+
return "linear_only";
|
|
85
|
+
}
|
|
86
|
+
return "publish_pr";
|
|
87
|
+
}
|
|
88
|
+
function buildPromptHeader(issue) {
|
|
89
|
+
return [
|
|
90
|
+
`Issue: ${issue.issueKey ?? issue.linearIssueId}`,
|
|
91
|
+
issue.title ? `Title: ${issue.title}` : undefined,
|
|
92
|
+
issue.branchName ? `Branch: ${issue.branchName}` : undefined,
|
|
93
|
+
issue.prNumber ? `PR: #${issue.prNumber}` : undefined,
|
|
94
|
+
].filter(Boolean).join("\n");
|
|
95
|
+
}
|
|
96
|
+
function extractIssueSection(description, heading) {
|
|
97
|
+
if (!description)
|
|
98
|
+
return undefined;
|
|
99
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
+
const pattern = new RegExp(`^## ${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|$)`, "im");
|
|
101
|
+
const match = description.match(pattern);
|
|
102
|
+
const body = match?.[1]?.trim();
|
|
103
|
+
return body && body.length > 0 ? body : undefined;
|
|
104
|
+
}
|
|
105
|
+
function extractIssueIntroText(description) {
|
|
106
|
+
if (!description)
|
|
107
|
+
return undefined;
|
|
108
|
+
const trimmed = description.trim();
|
|
109
|
+
if (!trimmed)
|
|
110
|
+
return undefined;
|
|
111
|
+
const firstSectionIndex = trimmed.search(/^##\s+/m);
|
|
112
|
+
const intro = firstSectionIndex === -1 ? trimmed : trimmed.slice(0, firstSectionIndex).trim();
|
|
113
|
+
return intro.length > 0 ? intro : undefined;
|
|
114
|
+
}
|
|
115
|
+
function buildTaskObjective(issue) {
|
|
116
|
+
const intro = extractIssueIntroText(issue.description);
|
|
117
|
+
return [
|
|
118
|
+
"## Task Objective",
|
|
119
|
+
"",
|
|
120
|
+
issue.title || `Complete ${issue.issueKey ?? issue.linearIssueId}.`,
|
|
121
|
+
...(intro ? ["", intro] : []),
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
function buildScopeDiscipline(issue) {
|
|
125
|
+
const description = issue.description?.trim();
|
|
126
|
+
const scope = extractIssueSection(description, "Scope");
|
|
127
|
+
const acceptance = extractIssueSection(description, "Acceptance criteria")
|
|
128
|
+
?? extractIssueSection(description, "Success criteria");
|
|
129
|
+
const relevantCode = extractIssueSection(description, "Relevant code");
|
|
130
|
+
return [
|
|
131
|
+
"## Scope Discipline",
|
|
132
|
+
"",
|
|
133
|
+
"Stay inside the delegated task.",
|
|
134
|
+
"Finish the issue completely enough to satisfy its stated scope and acceptance criteria, but do not widen it into unrelated product polish or follow-up cleanup.",
|
|
135
|
+
"Only broaden to adjacent routes, copy, or supporting surfaces when the issue text or repository guidance explicitly says they are the same user flow.",
|
|
136
|
+
"If you notice a worthwhile broader inconsistency that is not required to make this task correct, mention it in your summary as follow-up context instead of expanding the implementation.",
|
|
137
|
+
"",
|
|
138
|
+
...(scope ? ["### In Scope", "", scope, ""] : []),
|
|
139
|
+
...(acceptance ? ["### Acceptance / Done", "", acceptance, ""] : []),
|
|
140
|
+
...(relevantCode ? ["### Relevant Code", "", relevantCode, ""] : []),
|
|
141
|
+
"### Likely Review Invariants",
|
|
142
|
+
"",
|
|
143
|
+
"- Check the surfaces explicitly named in the task before stopping.",
|
|
144
|
+
"- If repository guidance says certain changed surfaces are one flow, verify that shared flow, but do not treat unrelated surrounding cleanup as part of this task.",
|
|
145
|
+
"- A review repair should fix the concrete concern on the current head, not silently expand the Linear issue into a broader rewrite.",
|
|
146
|
+
].join("\n");
|
|
147
|
+
}
|
|
148
|
+
function buildHumanContext(context) {
|
|
149
|
+
const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
|
|
150
|
+
const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
|
|
151
|
+
const operatorPrompt = typeof context?.operatorPrompt === "string" ? context.operatorPrompt.trim() : "";
|
|
152
|
+
const userComment = typeof context?.userComment === "string" ? context.userComment.trim() : "";
|
|
153
|
+
const lines = [];
|
|
154
|
+
if (promptContext) {
|
|
155
|
+
lines.push("## Linear Session Context", "", promptContext, "");
|
|
156
|
+
}
|
|
157
|
+
if (latestPrompt) {
|
|
158
|
+
lines.push("## Latest Human Instruction", "", latestPrompt, "");
|
|
159
|
+
}
|
|
160
|
+
if (operatorPrompt) {
|
|
161
|
+
lines.push("## Operator Prompt", "", operatorPrompt, "");
|
|
162
|
+
}
|
|
163
|
+
if (userComment) {
|
|
164
|
+
lines.push("## Human Follow-up Comment", "", userComment, "");
|
|
165
|
+
}
|
|
166
|
+
return lines.length > 0 ? lines.join("\n").trim() : undefined;
|
|
167
|
+
}
|
|
168
|
+
function resolveRequestedChangesMode(runType, context) {
|
|
169
|
+
if (runType === "branch_upkeep") {
|
|
170
|
+
return "branch_upkeep";
|
|
171
|
+
}
|
|
172
|
+
return context?.reviewFixMode === "branch_upkeep" || context?.branchUpkeepRequired === true
|
|
173
|
+
? "branch_upkeep"
|
|
174
|
+
: "address_review_feedback";
|
|
175
|
+
}
|
|
176
|
+
function readReviewFixComments(context) {
|
|
177
|
+
const raw = context?.reviewComments;
|
|
178
|
+
if (!Array.isArray(raw)) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const comments = [];
|
|
182
|
+
for (const entry of raw) {
|
|
183
|
+
if (!entry || typeof entry !== "object")
|
|
184
|
+
continue;
|
|
185
|
+
const record = entry;
|
|
186
|
+
const body = typeof record.body === "string" ? record.body.trim() : "";
|
|
187
|
+
if (!body)
|
|
188
|
+
continue;
|
|
189
|
+
comments.push({
|
|
190
|
+
body,
|
|
191
|
+
...(typeof record.path === "string" ? { path: record.path } : {}),
|
|
192
|
+
...(typeof record.line === "number" ? { line: record.line } : {}),
|
|
193
|
+
...(typeof record.side === "string" ? { side: record.side } : {}),
|
|
194
|
+
...(typeof record.startLine === "number" ? { startLine: record.startLine } : {}),
|
|
195
|
+
...(typeof record.startSide === "string" ? { startSide: record.startSide } : {}),
|
|
196
|
+
...(typeof record.url === "string" ? { url: record.url } : {}),
|
|
197
|
+
...(typeof record.authorLogin === "string" ? { authorLogin: record.authorLogin } : {}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return comments;
|
|
201
|
+
}
|
|
202
|
+
function buildStructuredReviewContext(context) {
|
|
203
|
+
const reviewId = typeof context?.reviewId === "number" ? context.reviewId : undefined;
|
|
204
|
+
const reviewCommitId = typeof context?.reviewCommitId === "string" ? context.reviewCommitId : undefined;
|
|
205
|
+
const reviewUrl = typeof context?.reviewUrl === "string" ? context.reviewUrl : undefined;
|
|
206
|
+
const reviewComments = readReviewFixComments(context);
|
|
207
|
+
if (!reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const lines = ["## Structured Review Context", ""];
|
|
211
|
+
if (reviewId !== undefined)
|
|
212
|
+
lines.push(`Review ID: ${reviewId}`);
|
|
213
|
+
if (reviewCommitId)
|
|
214
|
+
lines.push(`Reviewed commit: ${reviewCommitId}`);
|
|
215
|
+
if (reviewUrl)
|
|
216
|
+
lines.push(`Review URL: ${reviewUrl}`);
|
|
217
|
+
if (reviewComments.length === 0) {
|
|
218
|
+
lines.push("No inline review comments were captured for this review.");
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
lines.push(`Inline review comments captured: ${reviewComments.length}`, "Resolve each comment below or verify it is already fixed on the current head before you stop.", "A requested-changes turn is only complete if you push a newer PR head or deliberately escalate because you are blocked.", "");
|
|
222
|
+
for (const comment of reviewComments) {
|
|
223
|
+
const location = comment.path
|
|
224
|
+
? `${comment.path}${comment.line !== undefined ? `:${comment.line}` : ""}${comment.side ? ` (${comment.side})` : ""}`
|
|
225
|
+
: "general";
|
|
226
|
+
lines.push(`- ${location}`);
|
|
227
|
+
lines.push(comment.body);
|
|
228
|
+
if (comment.url)
|
|
229
|
+
lines.push(` URL: ${comment.url}`);
|
|
230
|
+
}
|
|
231
|
+
return lines.join("\n");
|
|
232
|
+
}
|
|
233
|
+
function appendStructuredReviewContext(lines, context) {
|
|
234
|
+
const structured = buildStructuredReviewContext(context);
|
|
235
|
+
if (structured) {
|
|
236
|
+
lines.push(structured, "");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function buildRequestedChangesContext(runType, context) {
|
|
240
|
+
const mode = resolveRequestedChangesMode(runType, context);
|
|
241
|
+
const lines = [];
|
|
242
|
+
if (mode === "branch_upkeep") {
|
|
243
|
+
lines.push("## Branch Upkeep After Requested Changes", "", "The requested review changes may already be addressed, but GitHub still shows the PR branch as behind or dirty against the base branch.", "Update the existing PR branch onto the latest base branch, resolve conflicts carefully, rerun the narrowest relevant verification, and push a newer head.", "Do not open a new PR.", "");
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const reviewer = typeof context?.reviewerName === "string" ? context.reviewerName : undefined;
|
|
247
|
+
const reviewBody = typeof context?.reviewBody === "string" ? context.reviewBody.trim() : "";
|
|
248
|
+
lines.push("## Review Changes Requested", "", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "", "", "1. Start with the structured review context below. Treat the inline review comments as the primary repair checklist for this turn.", "2. Check the current diff (`git diff origin/main`) — a prior rebase may have already resolved some concerns.", "3. For each review point: if already resolved on the current head, note why. If not, fix it.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Run verification, commit, and push a newer head on the existing PR branch.", "6. Do not try to hand the same head back to review. If you cannot produce a new pushed head, stop and surface the blocker clearly.", "7. GitHub review happens after the new head is pushed and CI is green. Do not use `gh pr edit --add-reviewer` as part of this workflow.", "");
|
|
249
|
+
appendStructuredReviewContext(lines, context);
|
|
250
|
+
}
|
|
251
|
+
return lines.join("\n").trim();
|
|
252
|
+
}
|
|
253
|
+
function buildCiRepairContext(context) {
|
|
254
|
+
const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
|
|
255
|
+
? context.ciSnapshot
|
|
256
|
+
: undefined;
|
|
257
|
+
return [
|
|
258
|
+
"## CI Repair",
|
|
259
|
+
"",
|
|
260
|
+
"A full CI iteration has settled failed on your PR. Start from the specific failing check/job/step below on the latest remote PR branch tip, fix that concrete failure first, then push to the same PR branch.",
|
|
261
|
+
snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "",
|
|
262
|
+
snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "",
|
|
263
|
+
snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "",
|
|
264
|
+
context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "",
|
|
265
|
+
context?.checkName ? `Failed check: ${String(context.checkName)}` : "",
|
|
266
|
+
context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "",
|
|
267
|
+
context?.stepName ? `Failed step: ${String(context.stepName)}` : "",
|
|
268
|
+
context?.summary ? `Failure summary: ${String(context.summary)}` : "",
|
|
269
|
+
Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
|
|
270
|
+
? `Other failed checks in the settled snapshot (context only; ignore unless the logs show the same root cause):\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
|
|
271
|
+
: "",
|
|
272
|
+
context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "",
|
|
273
|
+
Array.isArray(context?.annotations) && context.annotations.length > 0
|
|
274
|
+
? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
|
|
275
|
+
: "",
|
|
276
|
+
"",
|
|
277
|
+
"Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.",
|
|
278
|
+
"Read the latest logs for the named failing check, fix that root cause, and only broaden scope when the logs show direct fallout from the same issue.",
|
|
279
|
+
"Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.",
|
|
280
|
+
"Run focused verification for the named failure, then commit and push.",
|
|
281
|
+
"Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.",
|
|
282
|
+
"Do not change test expectations unless the test is genuinely wrong.",
|
|
283
|
+
].filter(Boolean).join("\n");
|
|
284
|
+
}
|
|
285
|
+
function appendQueueRepairContext(lines, context) {
|
|
286
|
+
const queueContext = context?.mergeQueueContext;
|
|
287
|
+
if (!queueContext || typeof queueContext !== "object") {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const record = queueContext;
|
|
291
|
+
const conflictingFiles = Array.isArray(record.conflictingFiles)
|
|
292
|
+
? record.conflictingFiles.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
293
|
+
: [];
|
|
294
|
+
const operatorHints = Array.isArray(record.operatorHints)
|
|
295
|
+
? record.operatorHints.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
296
|
+
: [];
|
|
297
|
+
lines.push("## Merge Queue Context", "");
|
|
298
|
+
if (typeof record.baseBranch === "string") {
|
|
299
|
+
lines.push(`Base branch: ${record.baseBranch}`);
|
|
300
|
+
}
|
|
301
|
+
if (typeof record.baseSha === "string") {
|
|
302
|
+
lines.push(`Base SHA at eviction: ${record.baseSha}`);
|
|
303
|
+
}
|
|
304
|
+
if (typeof record.mergeCommitSha === "string") {
|
|
305
|
+
lines.push(`Synthetic merge commit SHA: ${record.mergeCommitSha}`);
|
|
306
|
+
}
|
|
307
|
+
if (typeof record.checkRunUrl === "string") {
|
|
308
|
+
lines.push(`Steward check run: ${record.checkRunUrl}`);
|
|
309
|
+
}
|
|
310
|
+
if (typeof record.incidentSummary === "string") {
|
|
311
|
+
lines.push(`Steward summary: ${record.incidentSummary}`);
|
|
312
|
+
}
|
|
313
|
+
if (conflictingFiles.length > 0) {
|
|
314
|
+
lines.push("Conflicting files:");
|
|
315
|
+
conflictingFiles.forEach((file) => lines.push(`- ${file}`));
|
|
316
|
+
}
|
|
317
|
+
if (operatorHints.length > 0) {
|
|
318
|
+
lines.push("", "Operator hints:");
|
|
319
|
+
operatorHints.forEach((hint) => lines.push(`- ${hint}`));
|
|
320
|
+
}
|
|
321
|
+
lines.push("");
|
|
322
|
+
}
|
|
323
|
+
function buildQueueRepairContext(context) {
|
|
324
|
+
const lines = [];
|
|
325
|
+
appendQueueRepairContext(lines, context);
|
|
326
|
+
lines.push("## Merge Queue Failure", "", "The merge queue rejected this PR. Rebase onto latest main and fix conflicts.", context?.failureReason ? `Failure reason: ${String(context.failureReason)}` : "", "", "Fetch and rebase onto latest main, resolve conflicts, run verification, push.", "If the conflict is a semantic contradiction, explain and stop.");
|
|
327
|
+
return lines.filter(Boolean).join("\n");
|
|
328
|
+
}
|
|
329
|
+
function buildFollowUpPromptPrelude(issue, runType, context) {
|
|
330
|
+
const wakeReason = typeof context?.wakeReason === "string" ? context.wakeReason : undefined;
|
|
331
|
+
const followUps = Array.isArray(context?.followUps) ? context.followUps : [];
|
|
332
|
+
const followUpLines = followUps
|
|
333
|
+
.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
334
|
+
.map((entry) => `${String(entry.type ?? "follow_up")} from ${String(entry.author ?? "unknown")}: ${String(entry.text ?? "").trim()}`.trim())
|
|
335
|
+
.filter((line) => !line.endsWith(":"));
|
|
336
|
+
const lines = [
|
|
337
|
+
"## Follow-up Turn",
|
|
338
|
+
"",
|
|
339
|
+
wakeReason === "direct_reply"
|
|
340
|
+
? "Why this turn exists: A human reply arrived for the outstanding question from the previous turn."
|
|
341
|
+
: wakeReason === "branch_upkeep"
|
|
342
|
+
? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
|
|
343
|
+
: wakeReason === "followup_comment"
|
|
344
|
+
? "Why this turn exists: A human follow-up comment arrived after the previous turn."
|
|
345
|
+
: `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
|
|
346
|
+
wakeReason === "direct_reply"
|
|
347
|
+
? "Required action now: Apply the latest human answer, continue from the current branch/session context, and publish the next concrete result."
|
|
348
|
+
: "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
|
|
349
|
+
"",
|
|
350
|
+
];
|
|
351
|
+
if (followUpLines.length > 0) {
|
|
352
|
+
lines.push("Recent updates:");
|
|
353
|
+
followUpLines.forEach((line) => lines.push(`- ${line}`));
|
|
354
|
+
lines.push("");
|
|
355
|
+
}
|
|
356
|
+
if (issue.prNumber || issue.prHeadSha || issue.prReviewState || context?.mergeStateStatus) {
|
|
357
|
+
lines.push("## Current PR Facts", "", `Fact freshness: ${context?.githubFactsFresh === true
|
|
358
|
+
? "refreshed immediately before this turn was created."
|
|
359
|
+
: "may now be stale; refresh before making irreversible decisions."}`, issue.prNumber ? `Current PR: #${issue.prNumber}` : "", issue.prHeadSha ? `Current relevant head SHA: ${issue.prHeadSha}` : "", issue.prReviewState ? `Current review state: ${issue.prReviewState}` : "", typeof context?.mergeStateStatus === "string" ? `Merge state against ${String(context?.baseBranch ?? "main")}: ${String(context.mergeStateStatus)}` : "");
|
|
360
|
+
}
|
|
361
|
+
return lines.filter(Boolean).join("\n");
|
|
362
|
+
}
|
|
363
|
+
function buildReactiveContext(runType, issue, context, followUp = false) {
|
|
364
|
+
const lines = [];
|
|
365
|
+
if (followUp) {
|
|
366
|
+
lines.push(buildFollowUpPromptPrelude(issue, runType, context), "");
|
|
367
|
+
}
|
|
368
|
+
switch (runType) {
|
|
369
|
+
case "ci_repair":
|
|
370
|
+
lines.push(buildCiRepairContext(context));
|
|
371
|
+
break;
|
|
372
|
+
case "review_fix":
|
|
373
|
+
case "branch_upkeep":
|
|
374
|
+
lines.push(buildRequestedChangesContext(runType, context));
|
|
375
|
+
break;
|
|
376
|
+
case "queue_repair":
|
|
377
|
+
lines.push(buildQueueRepairContext(context));
|
|
378
|
+
break;
|
|
379
|
+
default:
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
const content = lines.map((line) => line.trimEnd()).join("\n").trim();
|
|
383
|
+
return content.length > 0 ? content : undefined;
|
|
384
|
+
}
|
|
385
|
+
function buildWorkflowGuidance(repoPath, runType) {
|
|
386
|
+
const workflowBody = readWorkflowFile(repoPath, runType);
|
|
387
|
+
if (workflowBody)
|
|
388
|
+
return workflowBody;
|
|
389
|
+
if (runType === "implementation") {
|
|
390
|
+
return "Implement the Linear issue. Read the issue via MCP for details.";
|
|
391
|
+
}
|
|
392
|
+
return "";
|
|
393
|
+
}
|
|
394
|
+
function buildPublicationContract(runType, issue, context) {
|
|
395
|
+
const deliveryMode = runType === "implementation" && issue
|
|
396
|
+
? resolveImplementationDeliveryMode(issue, context)
|
|
397
|
+
: "publish_pr";
|
|
398
|
+
if (runType === "implementation" && deliveryMode === "linear_only") {
|
|
399
|
+
return [
|
|
400
|
+
"## Delivery Requirements",
|
|
401
|
+
"",
|
|
402
|
+
"This issue is planning/specification only.",
|
|
403
|
+
"Do not modify repo files or open a PR for this issue.",
|
|
404
|
+
"Deliver the result through Linear artifacts such as follow-up issues, documents, and a concise summary.",
|
|
405
|
+
"Leave the worktree clean before stopping.",
|
|
406
|
+
].join("\n");
|
|
407
|
+
}
|
|
408
|
+
if (runType === "implementation") {
|
|
409
|
+
return [
|
|
410
|
+
"## Publication Requirements",
|
|
411
|
+
"",
|
|
412
|
+
"Before finishing, publish the result instead of leaving it only in the worktree.",
|
|
413
|
+
"If the worktree already contains relevant changes for this issue, verify them and publish them.",
|
|
414
|
+
"If you changed files for this issue, commit them, push the issue branch, and open or update the PR before stopping.",
|
|
415
|
+
"Do not stop with only local commits or uncommitted changes.",
|
|
416
|
+
].join("\n");
|
|
417
|
+
}
|
|
418
|
+
return [
|
|
419
|
+
"## Publication Requirements",
|
|
420
|
+
"",
|
|
421
|
+
"Before finishing, publish the result to the existing PR branch.",
|
|
422
|
+
"If you changed files for this repair, commit them and push the same branch before stopping.",
|
|
423
|
+
"Do not open a new PR.",
|
|
424
|
+
"Do not stop with only local commits or uncommitted changes.",
|
|
425
|
+
].join("\n");
|
|
426
|
+
}
|
|
427
|
+
function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
428
|
+
const sections = [
|
|
429
|
+
{ id: "header", content: buildPromptHeader(issue) },
|
|
430
|
+
];
|
|
431
|
+
const reactiveContext = buildReactiveContext(runType, issue, context, followUp);
|
|
432
|
+
if (followUp && reactiveContext) {
|
|
433
|
+
sections.push({ id: "follow-up-turn", content: reactiveContext });
|
|
434
|
+
}
|
|
435
|
+
sections.push({ id: "task-objective", content: buildTaskObjective(issue) }, { id: "scope-discipline", content: buildScopeDiscipline(issue) });
|
|
436
|
+
const humanContext = buildHumanContext(context);
|
|
437
|
+
if (humanContext) {
|
|
438
|
+
sections.push({ id: "human-context", content: humanContext });
|
|
439
|
+
}
|
|
440
|
+
if (!followUp && reactiveContext) {
|
|
441
|
+
sections.push({ id: "reactive-context", content: reactiveContext });
|
|
442
|
+
}
|
|
443
|
+
const workflow = buildWorkflowGuidance(repoPath, runType);
|
|
444
|
+
if (workflow) {
|
|
445
|
+
sections.push({ id: "workflow-guidance", content: workflow });
|
|
446
|
+
}
|
|
447
|
+
sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issue, context) });
|
|
448
|
+
return sections;
|
|
449
|
+
}
|
|
450
|
+
function filterAllowedReplacements(promptLayer) {
|
|
451
|
+
const allowed = new Set(PATCHRELAY_REPLACEABLE_SECTION_IDS);
|
|
452
|
+
const replacements = new Map();
|
|
453
|
+
for (const [sectionId, fragment] of Object.entries(promptLayer?.replaceSections ?? {})) {
|
|
454
|
+
if (!allowed.has(sectionId)) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
replacements.set(sectionId, fragment.content);
|
|
458
|
+
}
|
|
459
|
+
return replacements;
|
|
460
|
+
}
|
|
461
|
+
function applyPromptLayer(sections, promptLayer) {
|
|
462
|
+
if (!promptLayer) {
|
|
463
|
+
return sections;
|
|
464
|
+
}
|
|
465
|
+
const replacements = filterAllowedReplacements(promptLayer);
|
|
466
|
+
const replaced = sections.map((section) => ({
|
|
467
|
+
...section,
|
|
468
|
+
content: replacements.get(section.id) ?? section.content,
|
|
469
|
+
})).filter((section) => section.content.trim().length > 0);
|
|
470
|
+
if (!promptLayer.extraInstructions || promptLayer.extraInstructions.content.trim().length === 0) {
|
|
471
|
+
return replaced;
|
|
472
|
+
}
|
|
473
|
+
const workflowIndex = replaced.findIndex((section) => section.id === "workflow-guidance");
|
|
474
|
+
const extraSection = {
|
|
475
|
+
id: "extra-instructions",
|
|
476
|
+
content: ["## Extra Instructions", "", promptLayer.extraInstructions.content.trim()].join("\n"),
|
|
477
|
+
};
|
|
478
|
+
if (workflowIndex === -1) {
|
|
479
|
+
return [...replaced, extraSection];
|
|
480
|
+
}
|
|
481
|
+
return [
|
|
482
|
+
...replaced.slice(0, workflowIndex),
|
|
483
|
+
extraSection,
|
|
484
|
+
...replaced.slice(workflowIndex),
|
|
485
|
+
];
|
|
486
|
+
}
|
|
487
|
+
function renderPromptSections(sections) {
|
|
488
|
+
return sections
|
|
489
|
+
.map((section) => section.content.trim())
|
|
490
|
+
.filter(Boolean)
|
|
491
|
+
.join("\n\n");
|
|
492
|
+
}
|
|
493
|
+
function shouldBuildFollowUpPrompt(runType, context) {
|
|
494
|
+
if (context?.followUpMode)
|
|
495
|
+
return true;
|
|
496
|
+
if (runType !== "implementation")
|
|
497
|
+
return true;
|
|
498
|
+
const wakeReason = typeof context?.wakeReason === "string" ? context.wakeReason : undefined;
|
|
499
|
+
return Boolean(wakeReason && wakeReason !== "delegated");
|
|
500
|
+
}
|
|
501
|
+
export function resolvePromptLayers(config, runType) {
|
|
502
|
+
return mergePromptCustomizationLayers(config?.default, config?.byRunType[runType]);
|
|
503
|
+
}
|
|
504
|
+
export function mergePromptCustomizationLayers(base, override) {
|
|
505
|
+
if (!base && !override) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
...(override?.extraInstructions
|
|
510
|
+
? { extraInstructions: override.extraInstructions }
|
|
511
|
+
: base?.extraInstructions
|
|
512
|
+
? { extraInstructions: base.extraInstructions }
|
|
513
|
+
: {}),
|
|
514
|
+
replaceSections: {
|
|
515
|
+
...(base?.replaceSections ?? {}),
|
|
516
|
+
...(override?.replaceSections ?? {}),
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
export function findUnknownPatchRelayPromptSectionIds(promptLayer) {
|
|
521
|
+
const known = new Set(PATCHRELAY_PROMPT_SECTION_IDS);
|
|
522
|
+
const unknown = new Set();
|
|
523
|
+
for (const sectionId of Object.keys(promptLayer?.replaceSections ?? {})) {
|
|
524
|
+
if (!known.has(sectionId)) {
|
|
525
|
+
unknown.add(sectionId);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return [...unknown];
|
|
529
|
+
}
|
|
530
|
+
export function findDisallowedPatchRelayPromptSectionIds(promptLayer) {
|
|
531
|
+
const allowed = new Set(PATCHRELAY_REPLACEABLE_SECTION_IDS);
|
|
532
|
+
const known = new Set(PATCHRELAY_PROMPT_SECTION_IDS);
|
|
533
|
+
const disallowed = new Set();
|
|
534
|
+
for (const sectionId of Object.keys(promptLayer?.replaceSections ?? {})) {
|
|
535
|
+
if (known.has(sectionId) && !allowed.has(sectionId)) {
|
|
536
|
+
disallowed.add(sectionId);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return [...disallowed];
|
|
540
|
+
}
|
|
541
|
+
export function buildInitialRunPrompt(params) {
|
|
542
|
+
return renderPromptSections(applyPromptLayer(buildSections(params.issue, params.runType, params.repoPath, params.context, false), params.promptLayer));
|
|
543
|
+
}
|
|
544
|
+
export function buildFollowUpRunPrompt(params) {
|
|
545
|
+
return renderPromptSections(applyPromptLayer(buildSections(params.issue, params.runType, params.repoPath, params.context, true), params.promptLayer));
|
|
546
|
+
}
|
|
547
|
+
export function buildRunPrompt(params) {
|
|
548
|
+
if (shouldBuildFollowUpPrompt(params.runType, params.context)) {
|
|
549
|
+
return buildFollowUpRunPrompt(params);
|
|
550
|
+
}
|
|
551
|
+
return buildInitialRunPrompt(params);
|
|
552
|
+
}
|