patchrelay 0.7.9 → 0.8.0

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.
@@ -3,6 +3,7 @@ import { syncFailedStageToLinear } from "./stage-failure.js";
3
3
  import { buildFailedStageReport } from "./stage-reporting.js";
4
4
  import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
5
5
  import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
6
+ import { safeJsonParse } from "./utils.js";
6
7
  import { WorktreeManager } from "./worktree-manager.js";
7
8
  export class ServiceStageRunner {
8
9
  config;
@@ -47,7 +48,20 @@ export class ServiceStageRunner {
47
48
  return;
48
49
  }
49
50
  const existingWorkspace = this.stores.workspaceOwnership.getWorkspaceOwnershipForIssue(item.projectId, item.issueId);
50
- const defaultPlan = buildStageLaunchPlan(project, issue, desiredStage);
51
+ const stageHistory = this.stores.issueWorkflows.listStageRunsForIssue(item.projectId, item.issueId);
52
+ const previousStageRun = stageHistory.at(-1);
53
+ const defaultPlan = buildStageLaunchPlan(project, issue, desiredStage, {
54
+ ...(previousStageRun ? { previousStageRun } : {}),
55
+ ...(existingWorkspace
56
+ ? {
57
+ workspace: {
58
+ branchName: existingWorkspace.branchName,
59
+ worktreePath: existingWorkspace.worktreePath,
60
+ },
61
+ }
62
+ : {}),
63
+ stageHistory,
64
+ });
51
65
  const plan = existingWorkspace
52
66
  ? {
53
67
  ...defaultPlan,
@@ -61,6 +75,7 @@ export class ServiceStageRunner {
61
75
  issueKey: issue.issueKey,
62
76
  projectId: item.projectId,
63
77
  stage: desiredStage,
78
+ ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
64
79
  status: "starting",
65
80
  summary: `Starting ${desiredStage} workflow`,
66
81
  detail: `Preparing ${plan.branchName}`,
@@ -85,12 +100,17 @@ export class ServiceStageRunner {
85
100
  allowExistingOutsideRoot: existingWorkspace !== undefined,
86
101
  });
87
102
  await this.lifecyclePublisher.markStageActive(project, claim.issue, claim.stageRun);
88
- threadLaunch = await this.launchStageThread(item.projectId, item.issueId, claim.stageRun.id, plan.worktreePath, issue.issueKey);
103
+ threadLaunch = await this.launchStageThread(item.projectId, item.issueId, claim.stageRun.id, plan.worktreePath);
104
+ const pendingLaunchInput = this.collectPendingLaunchInput(item.projectId, item.issueId);
105
+ const initialTurnInput = pendingLaunchInput.combinedInput
106
+ ? [plan.prompt, "", pendingLaunchInput.combinedInput].join("\n")
107
+ : plan.prompt;
89
108
  turn = await this.codex.startTurn({
90
109
  threadId: threadLaunch.threadId,
91
110
  cwd: plan.worktreePath,
92
- input: plan.prompt,
111
+ input: initialTurnInput,
93
112
  });
113
+ this.completeDeliveredLaunchInput(pendingLaunchInput.obligationIds, claim.stageRun.id, threadLaunch.threadId, turn.turnId);
94
114
  }
95
115
  catch (error) {
96
116
  const err = error instanceof Error ? error : new Error(String(error));
@@ -122,19 +142,8 @@ export class ServiceStageRunner {
122
142
  turnId: turn.turnId,
123
143
  });
124
144
  this.inputDispatcher.routePendingInputs(claim.stageRun, threadLaunch.threadId, turn.turnId);
125
- await this.inputDispatcher.flush({
126
- id: claim.stageRun.id,
127
- projectId: claim.stageRun.projectId,
128
- linearIssueId: claim.stageRun.linearIssueId,
129
- threadId: threadLaunch.threadId,
130
- turnId: turn.turnId,
131
- }, {
132
- logFailures: true,
133
- failureMessage: "Failed to deliver queued Linear comment during stage startup",
134
- ...(claim.issue.issueKey ? { issueKey: claim.issue.issueKey } : {}),
135
- });
136
145
  const deliveredToSession = await this.lifecyclePublisher.publishStageStarted(claim.issue, claim.stageRun.stage);
137
- if (!deliveredToSession) {
146
+ if (!deliveredToSession && !claim.issue.activeAgentSessionId) {
138
147
  await this.lifecyclePublisher.refreshRunningStatusComment(item.projectId, item.issueId, claim.stageRun.id, issue.issueKey);
139
148
  }
140
149
  this.logger.info({
@@ -151,11 +160,45 @@ export class ServiceStageRunner {
151
160
  issueKey: issue.issueKey,
152
161
  projectId: item.projectId,
153
162
  stage: claim.stageRun.stage,
163
+ ...(claim.issue.selectedWorkflowId ? { workflowId: claim.issue.selectedWorkflowId } : {}),
154
164
  status: "running",
155
165
  summary: `Started ${claim.stageRun.stage} workflow`,
156
166
  detail: `Turn ${turn.turnId} is running in ${plan.branchName}.`,
157
167
  });
158
168
  }
169
+ collectPendingLaunchInput(projectId, issueId) {
170
+ const obligationIds = [];
171
+ const bodies = [];
172
+ for (const obligation of this.stores.obligations.listPendingObligations({ kind: "deliver_turn_input" })) {
173
+ if (obligation.projectId !== projectId ||
174
+ obligation.linearIssueId !== issueId ||
175
+ !obligation.source.startsWith("linear-agent-launch:")) {
176
+ continue;
177
+ }
178
+ const payload = safeJsonParse(obligation.payloadJson);
179
+ const body = payload?.body?.trim();
180
+ if (!body) {
181
+ this.stores.obligations.markObligationStatus(obligation.id, "failed", "obligation payload had no deliverable body");
182
+ continue;
183
+ }
184
+ obligationIds.push(obligation.id);
185
+ bodies.push(body);
186
+ }
187
+ return {
188
+ ...(bodies.length > 0 ? { combinedInput: bodies.join("\n\n") } : {}),
189
+ obligationIds,
190
+ };
191
+ }
192
+ completeDeliveredLaunchInput(obligationIds, runLeaseId, threadId, turnId) {
193
+ for (const obligationId of obligationIds) {
194
+ this.stores.obligations.updateObligationRouting(obligationId, {
195
+ runLeaseId,
196
+ threadId,
197
+ turnId,
198
+ });
199
+ this.stores.obligations.markObligationStatus(obligationId, "completed");
200
+ }
201
+ }
159
202
  async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
160
203
  const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
161
204
  if (existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
@@ -179,7 +222,7 @@ export class ServiceStageRunner {
179
222
  lastWebhookAt: new Date().toISOString(),
180
223
  });
181
224
  }
182
- async launchStageThread(projectId, issueId, stageRunId, worktreePath, issueKey) {
225
+ async launchStageThread(projectId, issueId, stageRunId, worktreePath) {
183
226
  const previousStageRun = this.stores.issueWorkflows
184
227
  .listStageRunsForIssue(projectId, issueId)
185
228
  .filter((stageRun) => stageRun.id !== stageRunId)
@@ -187,35 +230,10 @@ export class ServiceStageRunner {
187
230
  const parentThreadId = previousStageRun?.status === "completed" && isCodexThreadId(previousStageRun.threadId)
188
231
  ? previousStageRun.threadId
189
232
  : undefined;
190
- if (parentThreadId) {
191
- try {
192
- const thread = await this.codex.forkThread(parentThreadId, worktreePath);
193
- return {
194
- threadId: thread.id,
195
- parentThreadId,
196
- };
197
- }
198
- catch (error) {
199
- const err = error instanceof Error ? error : new Error(String(error));
200
- this.logger.warn({
201
- issueKey,
202
- parentThreadId,
203
- error: err.message,
204
- }, "Falling back to a fresh Codex thread after parent thread fork failed");
205
- this.feed?.publish({
206
- level: "warn",
207
- kind: "turn",
208
- issueKey,
209
- projectId,
210
- status: "fallback",
211
- summary: "Could not fork the previous Codex thread",
212
- detail: "Starting a fresh thread instead.",
213
- });
214
- }
215
- }
216
233
  const thread = await this.codex.startThread({ cwd: worktreePath });
217
234
  return {
218
235
  threadId: thread.id,
236
+ ...(parentThreadId ? { parentThreadId } : {}),
219
237
  };
220
238
  }
221
239
  async markLaunchFailed(project, issue, stageRun, message, threadId) {
@@ -45,7 +45,7 @@ export class ServiceWebhookProcessor {
45
45
  this.markEventReceiptProcessed(event.webhookId, "failed");
46
46
  throw new Error(`Stored webhook payload is invalid JSON: event ${webhookEventId}`);
47
47
  }
48
- const normalized = normalizeWebhook({
48
+ let normalized = normalizeWebhook({
49
49
  webhookId: event.webhookId,
50
50
  payload,
51
51
  });
@@ -69,12 +69,25 @@ export class ServiceWebhookProcessor {
69
69
  this.markEventReceiptProcessed(event.webhookId, "processed");
70
70
  return;
71
71
  }
72
- const project = resolveProject(this.config, normalized.issue);
72
+ let project = resolveProject(this.config, normalized.issue);
73
73
  if (!project) {
74
+ const routed = await this.tryHydrateProjectRoute(normalized);
75
+ if (routed) {
76
+ normalized = routed.normalized;
77
+ project = routed.project;
78
+ }
79
+ }
80
+ if (!project) {
81
+ const unresolvedIssue = normalized.issue;
82
+ if (!unresolvedIssue) {
83
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
84
+ this.markEventReceiptProcessed(event.webhookId, "failed");
85
+ throw new Error(`Normalized issue context disappeared before routing webhook ${event.webhookId}`);
86
+ }
74
87
  this.feed?.publish({
75
88
  level: "warn",
76
89
  kind: "webhook",
77
- issueKey: normalized.issue.identifier,
90
+ issueKey: unresolvedIssue.identifier,
78
91
  status: "ignored",
79
92
  summary: "Ignored webhook with no matching project route",
80
93
  detail: normalized.triggerEvent,
@@ -82,21 +95,27 @@ export class ServiceWebhookProcessor {
82
95
  this.logger.info({
83
96
  webhookEventId,
84
97
  webhookId: event.webhookId,
85
- issueKey: normalized.issue.identifier,
86
- issueId: normalized.issue.id,
87
- teamId: normalized.issue.teamId,
88
- teamKey: normalized.issue.teamKey,
98
+ issueKey: unresolvedIssue.identifier,
99
+ issueId: unresolvedIssue.id,
100
+ teamId: unresolvedIssue.teamId,
101
+ teamKey: unresolvedIssue.teamKey,
89
102
  triggerEvent: normalized.triggerEvent,
90
103
  }, "Ignoring webhook because no project route matched the Linear issue");
91
104
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
92
105
  this.markEventReceiptProcessed(event.webhookId, "processed");
93
106
  return;
94
107
  }
108
+ const routedIssue = normalized.issue;
109
+ if (!routedIssue) {
110
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
111
+ this.markEventReceiptProcessed(event.webhookId, "failed");
112
+ throw new Error(`Normalized issue context disappeared while routing webhook ${event.webhookId}`);
113
+ }
95
114
  if (!trustedActorAllowed(project, normalized.actor)) {
96
115
  this.feed?.publish({
97
116
  level: "warn",
98
117
  kind: "webhook",
99
- issueKey: normalized.issue.identifier,
118
+ issueKey: routedIssue.identifier,
100
119
  projectId: project.id,
101
120
  status: "ignored",
102
121
  summary: "Ignored webhook from an untrusted actor",
@@ -111,14 +130,15 @@ export class ServiceWebhookProcessor {
111
130
  actorEmail: normalized.actor?.email,
112
131
  }, "Ignoring webhook from untrusted Linear actor");
113
132
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
114
- this.assignEventReceiptContext(event.webhookId, project.id, normalized.issue.id);
133
+ this.assignEventReceiptContext(event.webhookId, project.id, routedIssue.id);
115
134
  this.markEventReceiptProcessed(event.webhookId, "processed");
116
135
  return;
117
136
  }
118
137
  this.stores.webhookEvents.assignWebhookProject(webhookEventId, project.id);
119
- const receipt = this.ensureEventReceipt(event, project.id, normalized.issue.id);
138
+ const receipt = this.ensureEventReceipt(event, project.id, routedIssue.id);
120
139
  const hydrated = await this.hydrateIssueContext(project.id, normalized);
121
- const hydratedIssue = hydrated.issue ?? normalized.issue;
140
+ const hydratedIssue = hydrated.issue ?? routedIssue;
141
+ const priorIssue = this.stores.issueWorkflows.getTrackedIssue(project.id, hydratedIssue.id);
122
142
  const issueState = this.desiredStageRecorder.record(project, hydrated, receipt ? { eventReceiptId: receipt.id } : undefined);
123
143
  const observation = describeWebhookObservation(hydrated, issueState.delegatedToPatchRelay);
124
144
  if (observation) {
@@ -127,11 +147,29 @@ export class ServiceWebhookProcessor {
127
147
  kind: observation.kind,
128
148
  issueKey: hydratedIssue.identifier,
129
149
  projectId: project.id,
150
+ ...(issueState.issue?.selectedWorkflowId ? { workflowId: issueState.issue.selectedWorkflowId } : {}),
130
151
  ...(observation.status ? { status: observation.status } : {}),
131
152
  summary: observation.summary,
132
153
  ...(observation.detail ? { detail: observation.detail } : {}),
133
154
  });
134
155
  }
156
+ if (issueState.issue?.selectedWorkflowId &&
157
+ issueState.issue.selectedWorkflowId !== priorIssue?.selectedWorkflowId &&
158
+ (hydrated.triggerEvent === "agentSessionCreated" || hydrated.triggerEvent === "agentPrompted")) {
159
+ this.feed?.publish({
160
+ level: "info",
161
+ kind: "workflow",
162
+ issueKey: hydratedIssue.identifier,
163
+ projectId: project.id,
164
+ workflowId: issueState.issue.selectedWorkflowId,
165
+ ...(issueState.desiredStage ? { nextStage: issueState.desiredStage } : {}),
166
+ status: "selected",
167
+ summary: `Selected ${issueState.issue.selectedWorkflowId} workflow`,
168
+ detail: issueState.desiredStage
169
+ ? `PatchRelay will start with ${issueState.desiredStage} from ${hydratedIssue.stateName ?? "the current Linear state"}.`
170
+ : undefined,
171
+ });
172
+ }
135
173
  await this.agentSessionHandler.handle({
136
174
  normalized: hydrated,
137
175
  project,
@@ -149,6 +187,7 @@ export class ServiceWebhookProcessor {
149
187
  issueKey: hydratedIssue.identifier,
150
188
  projectId: project.id,
151
189
  stage: issueState.desiredStage,
190
+ ...(issueState.issue?.selectedWorkflowId ? { workflowId: issueState.issue.selectedWorkflowId } : {}),
152
191
  status: "queued",
153
192
  summary: `Queued ${issueState.desiredStage} workflow`,
154
193
  detail: `Triggered by ${hydrated.triggerEvent}${hydratedIssue.stateName ? ` from ${hydratedIssue.stateName}` : ""}.`,
@@ -229,6 +268,43 @@ export class ServiceWebhookProcessor {
229
268
  return normalized;
230
269
  }
231
270
  }
271
+ async tryHydrateProjectRoute(normalized) {
272
+ if (!normalized.issue) {
273
+ return undefined;
274
+ }
275
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
276
+ return undefined;
277
+ }
278
+ for (const candidate of this.config.projects) {
279
+ const linear = await this.linearProvider.forProject(candidate.id);
280
+ if (!linear) {
281
+ continue;
282
+ }
283
+ try {
284
+ const liveIssue = await linear.getIssue(normalized.issue.id);
285
+ const hydrated = {
286
+ ...normalized,
287
+ issue: mergeIssueMetadata(normalized.issue, liveIssue),
288
+ };
289
+ const resolved = resolveProject(this.config, hydrated.issue);
290
+ if (resolved) {
291
+ return {
292
+ project: resolved,
293
+ normalized: hydrated,
294
+ };
295
+ }
296
+ }
297
+ catch (error) {
298
+ this.logger.debug({
299
+ candidateProjectId: candidate.id,
300
+ issueId: normalized.issue.id,
301
+ triggerEvent: normalized.triggerEvent,
302
+ error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
303
+ }, "Failed to hydrate Linear issue context while resolving project route");
304
+ }
305
+ }
306
+ return undefined;
307
+ }
232
308
  assignEventReceiptContext(webhookId, projectId, linearIssueId) {
233
309
  const receipt = this.lookupEventReceipt(webhookId);
234
310
  if (!receipt) {
package/dist/service.js CHANGED
@@ -12,6 +12,7 @@ function createServiceStores(db) {
12
12
  webhookEvents: db.webhookEvents,
13
13
  eventReceipts: db.eventReceipts,
14
14
  issueControl: db.issueControl,
15
+ issueSessions: db.issueSessions,
15
16
  workspaceOwnership: db.workspaceOwnership,
16
17
  runLeases: db.runLeases,
17
18
  obligations: db.obligations,
@@ -9,10 +9,10 @@ export async function syncFailedStageToLinear(params) {
9
9
  if (!linear) {
10
10
  return;
11
11
  }
12
- const fallbackState = resolveFallbackLinearState(params.project, params.stageRun.stage);
12
+ const fallbackState = resolveFallbackLinearState(params.project, params.stageRun.stage, params.issue.selectedWorkflowId);
13
13
  let shouldWriteFailureState = true;
14
14
  if (params.requireActiveLinearStateMatch) {
15
- const activeState = resolveActiveLinearState(params.project, params.stageRun.stage);
15
+ const activeState = resolveActiveLinearState(params.project, params.stageRun.stage, params.issue.selectedWorkflowId);
16
16
  if (!activeState) {
17
17
  shouldWriteFailureState = false;
18
18
  }
@@ -71,7 +71,7 @@ export async function syncFailedStageToLinear(params) {
71
71
  .then(() => true)
72
72
  .catch(() => false)) || deliveredToSession;
73
73
  }
74
- if (!deliveredToSession) {
74
+ if (!deliveredToSession && !params.issue.activeAgentSessionId) {
75
75
  const result = await linear
76
76
  .upsertIssueComment({
77
77
  issueId: params.stageRun.linearIssueId,
@@ -0,0 +1,107 @@
1
+ import { safeJsonParse } from "./utils.js";
2
+ import { listAllowedTransitionTargets, listWorkflowStageIds, resolveWorkflowStageCandidate } from "./workflow-policy.js";
3
+ function normalize(value) {
4
+ const trimmed = value?.trim();
5
+ return trimmed ? trimmed.toLowerCase() : undefined;
6
+ }
7
+ function stripListPrefix(value) {
8
+ return value.replace(/^[-*•]\s+/, "").replace(/^\d+\.\s+/, "").trim();
9
+ }
10
+ function resolveTerminalTarget(value) {
11
+ const normalized = normalize(value)?.replace(/[\s_-]+/g, "");
12
+ if (!normalized) {
13
+ return undefined;
14
+ }
15
+ if (["done", "complete", "completed", "shipped", "ship"].includes(normalized)) {
16
+ return "done";
17
+ }
18
+ if (["humanneeded", "humaninput", "needsinput", "unclear", "unknown", "blocked", "ambiguous"].includes(normalized)) {
19
+ return "human_needed";
20
+ }
21
+ return undefined;
22
+ }
23
+ export function resolveWorkflowTarget(project, value) {
24
+ return resolveWorkflowTargetForDefinition(project, value);
25
+ }
26
+ export function resolveWorkflowTargetForDefinition(project, value, workflowDefinitionId) {
27
+ return resolveWorkflowStageCandidate(project, value, workflowDefinitionId) ?? resolveTerminalTarget(value);
28
+ }
29
+ function summarizeSignalsHumanNeeded(lines) {
30
+ const joined = normalize(lines.join(" "));
31
+ if (!joined) {
32
+ return false;
33
+ }
34
+ return ["blocked", "unclear", "ambiguous", "human input", "human needed", "need human", "cannot determine"].some((token) => joined.includes(token));
35
+ }
36
+ export function parseStageHandoff(project, assistantMessages, workflowDefinitionId) {
37
+ const latestMessage = [...assistantMessages].reverse().find((message) => typeof message === "string" && message.trim().length > 0);
38
+ if (!latestMessage) {
39
+ return undefined;
40
+ }
41
+ const lines = latestMessage
42
+ .split(/\r?\n/)
43
+ .map((line) => line.trimEnd());
44
+ const markerIndex = lines.findIndex((line) => /^stage result\s*:?\s*$/i.test(line.trim()));
45
+ const relevantLines = (markerIndex >= 0 ? lines.slice(markerIndex + 1) : lines)
46
+ .map((line) => stripListPrefix(line.trim()))
47
+ .filter(Boolean);
48
+ if (relevantLines.length === 0) {
49
+ return undefined;
50
+ }
51
+ const summaryLines = [];
52
+ let nextLikelyStageText;
53
+ let nextAttention;
54
+ for (const line of relevantLines) {
55
+ const nextStageMatch = line.match(/^next likely stage\s*:\s*(.+)$/i);
56
+ if (nextStageMatch) {
57
+ nextLikelyStageText = nextStageMatch[1]?.trim();
58
+ continue;
59
+ }
60
+ const attentionMatch = line.match(/^(next attention|what to watch|watch carefully|pay attention|human attention)\s*:\s*(.+)$/i);
61
+ if (attentionMatch) {
62
+ nextAttention = attentionMatch[2]?.trim();
63
+ continue;
64
+ }
65
+ summaryLines.push(line);
66
+ }
67
+ const resolvedNextStage = resolveWorkflowTargetForDefinition(project, nextLikelyStageText, workflowDefinitionId);
68
+ return {
69
+ sourceText: latestMessage,
70
+ summaryLines,
71
+ ...(nextLikelyStageText ? { nextLikelyStageText } : {}),
72
+ ...(nextAttention ? { nextAttention } : {}),
73
+ suggestsHumanNeeded: summarizeSignalsHumanNeeded(summaryLines),
74
+ ...(resolvedNextStage ? { resolvedNextStage } : {}),
75
+ };
76
+ }
77
+ export function extractPriorStageHandoff(project, stageRun, workflowDefinitionId) {
78
+ if (!stageRun?.reportJson) {
79
+ return undefined;
80
+ }
81
+ const report = safeJsonParse(stageRun.reportJson);
82
+ return report ? parseStageHandoff(project, report.assistantMessages, workflowDefinitionId) : undefined;
83
+ }
84
+ export function buildCarryForwardPrompt(params) {
85
+ const availableStages = listWorkflowStageIds(params.project, params.workflowDefinitionId);
86
+ const attemptNumber = params.stageHistory.filter((stageRun) => stageRun.stage === params.currentStage).length + 1;
87
+ const recentHistory = params.stageHistory.slice(-4).map((stageRun) => stageRun.stage);
88
+ const previousHandoff = extractPriorStageHandoff(params.project, params.previousStageRun, params.workflowDefinitionId);
89
+ const lines = [
90
+ `Workflow stage ids: ${availableStages.join(", ")}`,
91
+ `Allowed next targets from ${params.currentStage}: ${listAllowedTransitionTargets(params.project, params.currentStage, params.workflowDefinitionId).join(", ")}`,
92
+ `This is attempt ${attemptNumber} for the ${params.currentStage} stage.`,
93
+ recentHistory.length > 0 ? `Recent workflow history: ${recentHistory.join(" -> ")}` : undefined,
94
+ params.workspace?.branchName ? `Branch: ${params.workspace.branchName}` : undefined,
95
+ params.workspace?.worktreePath ? `Worktree: ${params.workspace.worktreePath}` : undefined,
96
+ params.previousStageRun ? "" : undefined,
97
+ params.previousStageRun ? "Carry-forward from the previous stage:" : undefined,
98
+ params.previousStageRun ? `- Prior stage: ${params.previousStageRun.stage}` : undefined,
99
+ previousHandoff?.summaryLines[0] ? `- Outcome: ${previousHandoff.summaryLines[0]}` : undefined,
100
+ previousHandoff && previousHandoff.summaryLines.length > 1
101
+ ? `- Key facts: ${previousHandoff.summaryLines.slice(1, 3).join(" ")}`
102
+ : undefined,
103
+ previousHandoff?.nextAttention ? `- Watch next: ${previousHandoff.nextAttention}` : undefined,
104
+ params.previousStageRun?.threadId ? `- Prior thread: ${params.previousStageRun.threadId}` : undefined,
105
+ ].filter((value) => Boolean(value));
106
+ return lines.length > 0 ? lines.join("\n") : undefined;
107
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { resolveWorkflowById } from "./workflow-policy.js";
3
+ import { buildCarryForwardPrompt } from "./stage-handoff.js";
4
+ import { listWorkflowStageIds, resolveWorkflowStageConfig } from "./workflow-policy.js";
4
5
  function slugify(value) {
5
6
  return value
6
7
  .toLowerCase()
@@ -17,8 +18,8 @@ export function isCodexThreadId(value) {
17
18
  }
18
19
  return !value.startsWith("missing-thread-") && !value.startsWith("launch-failed-");
19
20
  }
20
- export function buildStageLaunchPlan(project, issue, stage) {
21
- const workflow = resolveWorkflowById(project, stage);
21
+ export function buildStageLaunchPlan(project, issue, stage, options) {
22
+ const workflow = resolveWorkflowStageConfig(project, stage, issue.selectedWorkflowId);
22
23
  if (!workflow) {
23
24
  throw new Error(`Workflow "${stage}" is not configured for project ${project.id}`);
24
25
  }
@@ -26,15 +27,31 @@ export function buildStageLaunchPlan(project, issue, stage) {
26
27
  const slug = issue.title ? slugify(issue.title) : "";
27
28
  const branchSuffix = slug ? `${issueRef}-${slug}` : issueRef;
28
29
  return {
29
- branchName: `${project.branchPrefix}/${branchSuffix}`,
30
- worktreePath: path.join(project.worktreeRoot, issueRef),
30
+ branchName: options?.branchName ?? `${project.branchPrefix}/${branchSuffix}`,
31
+ worktreePath: options?.worktreePath ?? path.join(project.worktreeRoot, issueRef),
31
32
  workflowFile: workflow.workflowFile,
32
33
  stage,
33
- prompt: buildStagePrompt(issue, workflow.id, workflow.whenState, workflow.workflowFile),
34
+ prompt: buildStagePrompt(project, issue, workflow.id, workflow.whenState, workflow.workflowFile, {
35
+ branchName: options?.branchName ?? `${project.branchPrefix}/${branchSuffix}`,
36
+ worktreePath: options?.worktreePath ?? path.join(project.worktreeRoot, issueRef),
37
+ ...(issue.selectedWorkflowId ? { workflowDefinitionId: issue.selectedWorkflowId } : {}),
38
+ ...(options?.previousStageRun ? { previousStageRun: options.previousStageRun } : {}),
39
+ ...(options?.workspace ? { workspace: options.workspace } : {}),
40
+ stageHistory: options?.stageHistory ?? [],
41
+ }),
34
42
  };
35
43
  }
36
- export function buildStagePrompt(issue, stage, triggerState, workflowFile) {
44
+ export function buildStagePrompt(project, issue, stage, triggerState, workflowFile, options) {
37
45
  const workflowBody = existsSync(workflowFile) ? readFileSync(workflowFile, "utf8").trim() : "";
46
+ const carryForward = buildCarryForwardPrompt({
47
+ project,
48
+ currentStage: stage,
49
+ ...(options?.workflowDefinitionId ? { workflowDefinitionId: options.workflowDefinitionId } : {}),
50
+ ...(options?.previousStageRun ? { previousStageRun: options.previousStageRun } : {}),
51
+ ...(options?.workspace ? { workspace: options.workspace } : {}),
52
+ stageHistory: options?.stageHistory ?? [],
53
+ });
54
+ const availableStages = listWorkflowStageIds(project, options?.workflowDefinitionId).join(", ");
38
55
  return [
39
56
  `Issue: ${issue.issueKey ?? issue.linearIssueId}`,
40
57
  issue.title ? `Title: ${issue.title}` : undefined,
@@ -42,9 +59,22 @@ export function buildStagePrompt(issue, stage, triggerState, workflowFile) {
42
59
  issue.currentLinearState ? `Current Linear State: ${issue.currentLinearState}` : undefined,
43
60
  `Workflow: ${stage}`,
44
61
  `Triggered By State: ${triggerState}`,
62
+ options?.branchName ? `Branch: ${options.branchName}` : undefined,
63
+ options?.worktreePath ? `Worktree: ${options.worktreePath}` : undefined,
64
+ "",
65
+ "Complete only the current workflow stage. Do not invent a new workflow or skip directly to another stage.",
66
+ "If the correct next step is unclear, say so plainly and use `human_needed` as the next likely stage.",
67
+ "",
68
+ carryForward ? "Carry-forward Context:" : undefined,
69
+ carryForward,
45
70
  "",
46
71
  "Operate only inside the prepared worktree for this issue. Continue the issue lifecycle in this workspace.",
47
- "Capture a crisp summary of what you did, what changed, and what remains blocked so PatchRelay can publish a read-only report.",
72
+ "Use the repo workflow instructions below for this stage.",
73
+ "End with a short `Stage result:` section in plain text with exactly four bullets:",
74
+ "- what happened",
75
+ "- key facts or artifacts",
76
+ `- Next likely stage: one of ${availableStages}, done, or human_needed`,
77
+ "- what the next stage or human should pay attention to",
48
78
  "",
49
79
  `Workflow File: ${path.basename(workflowFile)}`,
50
80
  workflowBody,