@calltelemetry/openclaw-linear 0.4.0 → 0.5.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.
package/src/pipeline.ts CHANGED
@@ -1,270 +1,654 @@
1
+ /**
2
+ * pipeline.ts — Dispatch pipeline v2: hook-driven with hard-enforced audit.
3
+ *
4
+ * v1 (runFullPipeline) ran plan→implement→audit in a single synchronous flow
5
+ * with the same agent self-certifying its own work.
6
+ *
7
+ * v2 splits into:
8
+ * - Worker phase: orchestrator spawns worker via plugin code, worker plans + implements
9
+ * - Audit phase: agent_end hook auto-triggers independent audit (runAgent)
10
+ * - Verdict phase: agent_end hook processes audit result → done/rework/stuck
11
+ *
12
+ * Prompts are loaded from prompts.yaml (sidecar file, customizable).
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { parse as parseYaml } from "yaml";
1
18
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
19
  import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
3
20
  import { runAgent } from "./agent.js";
4
21
  import { setActiveSession, clearActiveSession } from "./active-session.js";
5
-
6
- export interface PipelineContext {
7
- api: OpenClawPluginApi;
8
- linearApi: LinearAgentApi;
9
- agentSessionId: string;
10
- agentId: string;
11
- issue: {
12
- id: string;
13
- identifier: string;
14
- title: string;
15
- description?: string | null;
16
- };
17
- promptContext?: unknown;
18
- /** Populated by implementor stage if Codex creates a worktree */
19
- worktreePath?: string | null;
20
- /** Codex branch name, e.g. codex/UAT-123 */
21
- codexBranch?: string | null;
22
+ import {
23
+ type Tier,
24
+ type DispatchStatus,
25
+ type ActiveDispatch,
26
+ type DispatchState,
27
+ type SessionMapping,
28
+ transitionDispatch,
29
+ registerSessionMapping,
30
+ markEventProcessed,
31
+ completeDispatch,
32
+ TransitionError,
33
+ readDispatchState,
34
+ getActiveDispatch,
35
+ } from "./dispatch-state.js";
36
+ import { type NotifyFn } from "./notify.js";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Prompt loading
40
+ // ---------------------------------------------------------------------------
41
+
42
+ interface PromptTemplates {
43
+ worker: { system: string; task: string };
44
+ audit: { system: string; task: string };
45
+ rework: { addendum: string };
22
46
  }
23
47
 
24
- function emit(ctx: PipelineContext, content: ActivityContent): Promise<void> {
25
- return ctx.linearApi.emitActivity(ctx.agentSessionId, content).catch((err) => {
26
- ctx.api.logger.error(`Failed to emit activity: ${err}`);
27
- });
48
+ const DEFAULT_PROMPTS: PromptTemplates = {
49
+ worker: {
50
+ system: "You are implementing a Linear issue. Post an implementation summary as a Linear comment when done. DO NOT mark the issue as Done.",
51
+ task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}",
52
+ },
53
+ audit: {
54
+ system: "You are an independent auditor. The Linear issue body is the SOURCE OF TRUTH. Worker comments are secondary evidence.",
55
+ task: 'Audit issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nReturn JSON verdict: {"pass": true/false, "criteria": [...], "gaps": [...], "testResults": "..."}',
56
+ },
57
+ rework: {
58
+ addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues.",
59
+ },
60
+ };
61
+
62
+ let _cachedPrompts: PromptTemplates | null = null;
63
+
64
+ export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
65
+ if (_cachedPrompts) return _cachedPrompts;
66
+
67
+ try {
68
+ // Try custom path first
69
+ const customPath = pluginConfig?.promptsPath as string | undefined;
70
+ let raw: string;
71
+
72
+ if (customPath) {
73
+ const resolved = customPath.startsWith("~")
74
+ ? customPath.replace("~", process.env.HOME ?? "")
75
+ : customPath;
76
+ raw = readFileSync(resolved, "utf-8");
77
+ } else {
78
+ // Load from plugin directory (sidecar file)
79
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
80
+ raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
81
+ }
82
+
83
+ const parsed = parseYaml(raw) as Partial<PromptTemplates>;
84
+ _cachedPrompts = {
85
+ worker: { ...DEFAULT_PROMPTS.worker, ...parsed.worker },
86
+ audit: { ...DEFAULT_PROMPTS.audit, ...parsed.audit },
87
+ rework: { ...DEFAULT_PROMPTS.rework, ...parsed.rework },
88
+ };
89
+ } catch {
90
+ _cachedPrompts = DEFAULT_PROMPTS;
91
+ }
92
+
93
+ return _cachedPrompts;
28
94
  }
29
95
 
30
- function toolContext(ctx: PipelineContext): string {
31
- return [
32
- `\n## Tool Context`,
33
- `When calling \`code_run\`, you MUST pass these parameters:`,
34
- `- \`agentSessionId\`: \`"${ctx.agentSessionId}"\``,
35
- `- \`issueIdentifier\`: \`"${ctx.issue.identifier}"\``,
36
- `This enables real-time progress streaming to Linear and isolated worktree creation.`,
37
- ].join("\n");
96
+ /** Clear prompt cache (for testing or after config change) */
97
+ export function clearPromptCache(): void {
98
+ _cachedPrompts = null;
38
99
  }
39
100
 
40
- // ── Stage 1: Planner ───────────────────────────────────────────────
101
+ function renderTemplate(template: string, vars: Record<string, string>): string {
102
+ let result = template;
103
+ for (const [key, value] of Object.entries(vars)) {
104
+ result = result.replaceAll(`{{${key}}}`, value);
105
+ }
106
+ return result;
107
+ }
41
108
 
42
- export async function runPlannerStage(ctx: PipelineContext): Promise<string | null> {
43
- await emit(ctx, { type: "thought", body: `Analyzing issue ${ctx.issue.identifier}...` });
109
+ // ---------------------------------------------------------------------------
110
+ // Task builders
111
+ // ---------------------------------------------------------------------------
44
112
 
45
- const issueDetails = await ctx.linearApi.getIssueDetails(ctx.issue.id).catch(() => null);
113
+ export interface IssueContext {
114
+ id: string;
115
+ identifier: string;
116
+ title: string;
117
+ description?: string | null;
118
+ }
46
119
 
47
- const description = issueDetails?.description ?? ctx.issue.description ?? "(no description)";
48
- const comments = issueDetails?.comments?.nodes ?? [];
49
- const commentSummary = comments
50
- .slice(-5)
51
- .map((c) => `${c.user?.name ?? "Unknown"}: ${c.body}`)
52
- .join("\n");
120
+ /**
121
+ * Build the task prompt for a worker sub-agent (sessions_spawn).
122
+ * Includes rework addendum if attempt > 0.
123
+ */
124
+ export function buildWorkerTask(
125
+ issue: IssueContext,
126
+ worktreePath: string,
127
+ opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
128
+ ): { system: string; task: string } {
129
+ const prompts = loadPrompts(opts?.pluginConfig);
130
+ const vars: Record<string, string> = {
131
+ identifier: issue.identifier,
132
+ title: issue.title,
133
+ description: issue.description ?? "(no description)",
134
+ worktreePath,
135
+ tier: "",
136
+ attempt: String(opts?.attempt ?? 0),
137
+ gaps: opts?.gaps?.join("\n- ") ?? "",
138
+ };
53
139
 
54
- const message = `You are a planner agent. Analyze this Linear issue and create an implementation plan.
140
+ let task = renderTemplate(prompts.worker.task, vars);
141
+ if ((opts?.attempt ?? 0) > 0 && opts?.gaps?.length) {
142
+ task += "\n\n" + renderTemplate(prompts.rework.addendum, vars);
143
+ }
55
144
 
56
- ## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
145
+ return {
146
+ system: renderTemplate(prompts.worker.system, vars),
147
+ task,
148
+ };
149
+ }
57
150
 
58
- **Description:**
59
- ${description}
151
+ /**
152
+ * Build the task prompt for an audit sub-agent (runAgent).
153
+ */
154
+ export function buildAuditTask(
155
+ issue: IssueContext,
156
+ worktreePath: string,
157
+ pluginConfig?: Record<string, unknown>,
158
+ ): { system: string; task: string } {
159
+ const prompts = loadPrompts(pluginConfig);
160
+ const vars: Record<string, string> = {
161
+ identifier: issue.identifier,
162
+ title: issue.title,
163
+ description: issue.description ?? "(no description)",
164
+ worktreePath,
165
+ tier: "",
166
+ attempt: "0",
167
+ gaps: "",
168
+ };
60
169
 
61
- ${commentSummary ? `**Recent comments:**\n${commentSummary}` : ""}
170
+ return {
171
+ system: renderTemplate(prompts.audit.system, vars),
172
+ task: renderTemplate(prompts.audit.task, vars),
173
+ };
174
+ }
62
175
 
63
- ${ctx.promptContext ? `**Additional context:**\n${JSON.stringify(ctx.promptContext)}` : ""}
176
+ // ---------------------------------------------------------------------------
177
+ // Verdict parsing
178
+ // ---------------------------------------------------------------------------
64
179
 
65
- ## Instructions
66
- 1. Analyze the issue thoroughly
67
- 2. Break it into concrete implementation steps
68
- 3. Identify files that need to change
69
- 4. Note any risks or dependencies
70
- 5. Output your plan in markdown format
180
+ export interface AuditVerdict {
181
+ pass: boolean;
182
+ criteria: string[];
183
+ gaps: string[];
184
+ testResults: string;
185
+ }
71
186
 
72
- IMPORTANT: Do NOT call code_run or any coding tools. Your job is ONLY to analyze and write a plan. The implementor stage will execute the plan using code_run after you're done.
187
+ /**
188
+ * Parse the audit verdict JSON from the agent's output.
189
+ * Looks for the last JSON object in the output that matches the verdict shape.
190
+ */
191
+ export function parseVerdict(output: string): AuditVerdict | null {
192
+ // Try to find JSON in the output (last occurrence)
193
+ const jsonMatches = output.match(/\{[^{}]*"pass"\s*:\s*(true|false)[^{}]*\}/g);
194
+ if (!jsonMatches?.length) return null;
195
+
196
+ for (const match of jsonMatches.reverse()) {
197
+ try {
198
+ const parsed = JSON.parse(match);
199
+ if (typeof parsed.pass === "boolean") {
200
+ return {
201
+ pass: parsed.pass,
202
+ criteria: Array.isArray(parsed.criteria) ? parsed.criteria : [],
203
+ gaps: Array.isArray(parsed.gaps) ? parsed.gaps : [],
204
+ testResults: typeof parsed.testResults === "string" ? parsed.testResults : "",
205
+ };
206
+ }
207
+ } catch { /* try next match */ }
208
+ }
73
209
 
74
- Output ONLY the plan, nothing else.`;
210
+ return null;
211
+ }
75
212
 
76
- await emit(ctx, { type: "action", action: "Planning", parameter: ctx.issue.identifier });
213
+ // ---------------------------------------------------------------------------
214
+ // Hook handlers (called by agent_end hook in index.ts)
215
+ // ---------------------------------------------------------------------------
77
216
 
78
- const result = await runAgent({
79
- api: ctx.api,
80
- agentId: ctx.agentId,
81
- sessionId: `linear-plan-${ctx.agentSessionId}`,
82
- message,
83
- timeoutMs: 5 * 60_000,
217
+ export interface HookContext {
218
+ api: OpenClawPluginApi;
219
+ linearApi: LinearAgentApi;
220
+ notify: NotifyFn;
221
+ pluginConfig?: Record<string, unknown>;
222
+ configPath?: string;
223
+ }
84
224
 
85
- });
225
+ /**
226
+ * Triggered by agent_end hook when a worker sub-agent completes.
227
+ * Transitions dispatch to "auditing" and spawns an independent audit agent.
228
+ *
229
+ * Idempotent: uses CAS transition + event dedup.
230
+ */
231
+ export async function triggerAudit(
232
+ hookCtx: HookContext,
233
+ dispatch: ActiveDispatch,
234
+ event: { messages?: unknown[]; success: boolean; output?: string },
235
+ sessionKey: string,
236
+ ): Promise<void> {
237
+ const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
238
+ const TAG = `[${dispatch.issueIdentifier}]`;
239
+
240
+ // Dedup check
241
+ const eventKey = `worker-end:${sessionKey}`;
242
+ const isNew = await markEventProcessed(eventKey, configPath);
243
+ if (!isNew) {
244
+ api.logger.info(`${TAG} duplicate worker agent_end, skipping`);
245
+ return;
246
+ }
86
247
 
87
- if (!result.success) {
88
- await emit(ctx, { type: "error", body: `Planning failed: ${result.output.slice(0, 500)}` });
89
- return null;
248
+ // CAS transition: working → auditing
249
+ try {
250
+ await transitionDispatch(
251
+ dispatch.issueIdentifier,
252
+ "working",
253
+ "auditing",
254
+ undefined,
255
+ configPath,
256
+ );
257
+ } catch (err) {
258
+ if (err instanceof TransitionError) {
259
+ api.logger.warn(`${TAG} CAS failed for audit trigger: ${err.message}`);
260
+ return;
261
+ }
262
+ throw err;
90
263
  }
91
264
 
92
- const plan = result.output;
265
+ api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
93
266
 
94
- // Post plan as a Linear comment
95
- await ctx.linearApi.createComment(
96
- ctx.issue.id,
97
- `## Implementation Plan\n\n${plan}\n\n---\n*Reply to this comment to approve the plan and begin implementation.*`,
98
- );
267
+ // Fetch fresh issue details for audit context
268
+ const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
269
+ const issue: IssueContext = {
270
+ id: dispatch.issueId,
271
+ identifier: dispatch.issueIdentifier,
272
+ title: issueDetails?.title ?? dispatch.issueIdentifier,
273
+ description: issueDetails?.description,
274
+ };
275
+
276
+ // Build audit prompt from YAML templates
277
+ const auditPrompt = buildAuditTask(issue, dispatch.worktreePath, pluginConfig);
278
+
279
+ // Set Linear label
280
+ await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
281
+ type: "thought",
282
+ body: `Audit triggered for ${dispatch.issueIdentifier} (attempt ${dispatch.attempt})`,
283
+ }).catch(() => {});
99
284
 
100
- await emit(ctx, {
101
- type: "elicitation",
102
- body: "I've posted an implementation plan as a comment. Please review and reply to approve.",
285
+ await notify("auditing", {
286
+ identifier: dispatch.issueIdentifier,
287
+ title: issue.title,
288
+ status: "auditing",
289
+ attempt: dispatch.attempt,
103
290
  });
104
291
 
105
- return plan;
106
- }
292
+ // Spawn audit agent via runAgent (deterministic, plugin-level — NOT sessions_spawn)
293
+ const auditSessionId = `linear-audit-${dispatch.issueIdentifier}-${dispatch.attempt}`;
294
+
295
+ // Register session mapping so the agent_end hook can find this dispatch
296
+ await registerSessionMapping(auditSessionId, {
297
+ dispatchId: dispatch.issueIdentifier,
298
+ phase: "audit",
299
+ attempt: dispatch.attempt,
300
+ }, configPath);
301
+
302
+ // Update dispatch with audit session key
303
+ const state = await readDispatchState(configPath);
304
+ const activeDispatch = getActiveDispatch(state, dispatch.issueIdentifier);
305
+ if (activeDispatch) {
306
+ activeDispatch.auditSessionKey = auditSessionId;
307
+ }
107
308
 
108
- // ── Stage 2: Implementor ──────────────────────────────────────────
309
+ api.logger.info(`${TAG} spawning audit agent session=${auditSessionId}`);
109
310
 
110
- export async function runImplementorStage(
111
- ctx: PipelineContext,
112
- plan: string,
113
- ): Promise<string | null> {
114
- await emit(ctx, { type: "thought", body: "Plan approved. Starting implementation..." });
311
+ const result = await runAgent({
312
+ api,
313
+ agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
314
+ sessionId: auditSessionId,
315
+ message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
316
+ timeoutMs: 5 * 60_000,
317
+ });
115
318
 
116
- const message = `You are an implementor agent. Execute this plan for issue ${ctx.issue.identifier}.
319
+ // runAgent returns inline (embedded runner) process verdict directly.
320
+ // The agent_end hook in index.ts serves as safety net for sessions_spawn.
321
+ api.logger.info(`${TAG} audit completed inline (${result.output.length} chars, success=${result.success})`);
117
322
 
118
- ## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
323
+ await processVerdict(hookCtx, dispatch, {
324
+ success: result.success,
325
+ output: result.output,
326
+ }, auditSessionId);
327
+ }
119
328
 
120
- ## Approved Plan:
121
- ${plan}
329
+ /**
330
+ * Triggered by agent_end hook when an audit sub-agent completes.
331
+ * Parses the verdict and transitions dispatch accordingly.
332
+ *
333
+ * Idempotent: uses CAS transition + event dedup.
334
+ */
335
+ export async function processVerdict(
336
+ hookCtx: HookContext,
337
+ dispatch: ActiveDispatch,
338
+ event: { messages?: unknown[]; success: boolean; output?: string },
339
+ sessionKey: string,
340
+ ): Promise<void> {
341
+ const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
342
+ const TAG = `[${dispatch.issueIdentifier}]`;
343
+ const maxAttempts = (pluginConfig?.maxReworkAttempts as number) ?? 2;
344
+
345
+ // Dedup check
346
+ const eventKey = `audit-end:${sessionKey}`;
347
+ const isNew = await markEventProcessed(eventKey, configPath);
348
+ if (!isNew) {
349
+ api.logger.info(`${TAG} duplicate audit agent_end, skipping`);
350
+ return;
351
+ }
122
352
 
123
- ## Instructions
124
- 1. Follow the plan step by step
125
- 2. Use \`code_run\` to write code, create files, run tests, and refactor — it works in an isolated git worktree
126
- 3. Use \`spawn_agent\` / \`ask_agent\` to delegate to other crew agents if needed
127
- 4. Create commits for each logical change
128
- 5. If the plan involves creating a PR, do so
129
- 6. Report what you did, any files changed, and the worktree/branch path
130
- ${toolContext(ctx)}
353
+ // Extract output from event messages or direct output
354
+ let auditOutput = event.output ?? "";
355
+ if (!auditOutput && Array.isArray(event.messages)) {
356
+ // Get the last assistant message
357
+ for (const msg of [...(event.messages as any[])].reverse()) {
358
+ if (msg?.role === "assistant" && typeof msg?.content === "string") {
359
+ auditOutput = msg.content;
360
+ break;
361
+ }
362
+ // Handle array content (tool use + text blocks)
363
+ if (msg?.role === "assistant" && Array.isArray(msg?.content)) {
364
+ for (const block of msg.content) {
365
+ if (block?.type === "text" && typeof block?.text === "string") {
366
+ auditOutput = block.text;
367
+ }
368
+ }
369
+ if (auditOutput) break;
370
+ }
371
+ }
372
+ }
131
373
 
132
- Be thorough but stay within scope of the plan.`;
374
+ // Parse verdict
375
+ const verdict = parseVerdict(auditOutput);
376
+ if (!verdict) {
377
+ api.logger.warn(`${TAG} could not parse audit verdict from output (${auditOutput.length} chars)`);
378
+ // Treat unparseable verdict as failure
379
+ await handleAuditFail(hookCtx, dispatch, {
380
+ pass: false,
381
+ criteria: [],
382
+ gaps: ["Audit produced no parseable verdict"],
383
+ testResults: "",
384
+ });
385
+ return;
386
+ }
133
387
 
134
- await emit(ctx, { type: "action", action: "Calling coding provider", parameter: "Codex" });
388
+ api.logger.info(
389
+ `${TAG} audit verdict: ${verdict.pass ? "PASS" : "FAIL"} ` +
390
+ `(criteria: ${verdict.criteria.length}, gaps: ${verdict.gaps.length})`,
391
+ );
135
392
 
136
- const result = await runAgent({
137
- api: ctx.api,
138
- agentId: ctx.agentId,
139
- sessionId: `linear-impl-${ctx.agentSessionId}`,
140
- message,
141
- timeoutMs: 10 * 60_000,
393
+ if (verdict.pass) {
394
+ await handleAuditPass(hookCtx, dispatch, verdict);
395
+ } else {
396
+ await handleAuditFail(hookCtx, dispatch, verdict);
397
+ }
398
+ }
142
399
 
143
- });
400
+ // ---------------------------------------------------------------------------
401
+ // Verdict handlers
402
+ // ---------------------------------------------------------------------------
403
+
404
+ async function handleAuditPass(
405
+ hookCtx: HookContext,
406
+ dispatch: ActiveDispatch,
407
+ verdict: AuditVerdict,
408
+ ): Promise<void> {
409
+ const { api, linearApi, notify, configPath } = hookCtx;
410
+ const TAG = `[${dispatch.issueIdentifier}]`;
144
411
 
145
- if (!result.success) {
146
- await emit(ctx, { type: "error", body: `Implementation failed: ${result.output.slice(0, 500)}` });
147
- return null;
412
+ // CAS transition: auditing → done
413
+ try {
414
+ await transitionDispatch(dispatch.issueIdentifier, "auditing", "done", undefined, configPath);
415
+ } catch (err) {
416
+ if (err instanceof TransitionError) {
417
+ api.logger.warn(`${TAG} CAS failed for audit pass: ${err.message}`);
418
+ return;
419
+ }
420
+ throw err;
148
421
  }
149
422
 
150
- // Try to extract worktree info from agent output (Codex results include it)
151
- const worktreeMatch = result.output.match(/worktreePath["\s:]+([/\w.-]+)/);
152
- const branchMatch = result.output.match(/branch["\s:]+([/\w.-]+)/);
153
- if (worktreeMatch) ctx.worktreePath = worktreeMatch[1];
154
- if (branchMatch) ctx.codexBranch = branchMatch[1];
423
+ // Move to completed
424
+ await completeDispatch(dispatch.issueIdentifier, {
425
+ tier: dispatch.tier,
426
+ status: "done",
427
+ completedAt: new Date().toISOString(),
428
+ project: dispatch.project,
429
+ }, configPath);
430
+
431
+ // Post approval comment
432
+ const criteriaList = verdict.criteria.map((c) => `- ${c}`).join("\n");
433
+ await linearApi.createComment(
434
+ dispatch.issueId,
435
+ `## Audit Passed\n\n**Criteria verified:**\n${criteriaList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Attempt ${dispatch.attempt + 1} — audit passed.*`,
436
+ ).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
437
+
438
+ api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
439
+
440
+ await notify("audit_pass", {
441
+ identifier: dispatch.issueIdentifier,
442
+ title: dispatch.issueIdentifier,
443
+ status: "done",
444
+ attempt: dispatch.attempt,
445
+ verdict: { pass: true, gaps: [] },
446
+ });
155
447
 
156
- await emit(ctx, { type: "action", action: "Implementation complete", parameter: ctx.issue.identifier });
157
- return result.output;
448
+ clearActiveSession(dispatch.issueId);
158
449
  }
159
450
 
160
- // ── Stage 3: Auditor ──────────────────────────────────────────────
161
-
162
- export async function runAuditorStage(
163
- ctx: PipelineContext,
164
- plan: string,
165
- implResult: string,
451
+ async function handleAuditFail(
452
+ hookCtx: HookContext,
453
+ dispatch: ActiveDispatch,
454
+ verdict: AuditVerdict,
166
455
  ): Promise<void> {
167
- await emit(ctx, { type: "thought", body: "Auditing implementation against the plan..." });
168
-
169
- const worktreeInfo = ctx.worktreePath
170
- ? `\n## Worktree\nCode changes are at: \`${ctx.worktreePath}\` (branch: \`${ctx.codexBranch ?? "unknown"}\`)\n`
171
- : "";
456
+ const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
457
+ const TAG = `[${dispatch.issueIdentifier}]`;
458
+ const maxAttempts = (pluginConfig?.maxReworkAttempts as number) ?? 2;
459
+ const nextAttempt = dispatch.attempt + 1;
460
+
461
+ if (nextAttempt > maxAttempts) {
462
+ // Escalate — too many failures
463
+ try {
464
+ await transitionDispatch(
465
+ dispatch.issueIdentifier,
466
+ "auditing",
467
+ "stuck",
468
+ { stuckReason: `audit_failed_${nextAttempt}x` },
469
+ configPath,
470
+ );
471
+ } catch (err) {
472
+ if (err instanceof TransitionError) {
473
+ api.logger.warn(`${TAG} CAS failed for stuck transition: ${err.message}`);
474
+ return;
475
+ }
476
+ throw err;
477
+ }
172
478
 
173
- const message = `You are an auditor. Review this implementation against the original plan.
479
+ const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
480
+ await linearApi.createComment(
481
+ dispatch.issueId,
482
+ `## Audit Failed — Escalating\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Max rework attempts reached. Needs human review.*`,
483
+ ).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
174
484
 
175
- ## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
485
+ api.logger.warn(`${TAG} audit FAILED ${nextAttempt}xescalating to human`);
176
486
 
177
- ## Original Plan:
178
- ${plan}
487
+ await notify("escalation", {
488
+ identifier: dispatch.issueIdentifier,
489
+ title: dispatch.issueIdentifier,
490
+ status: "stuck",
491
+ attempt: nextAttempt,
492
+ reason: `audit failed ${nextAttempt}x`,
493
+ verdict: { pass: false, gaps: verdict.gaps },
494
+ });
179
495
 
180
- ## Implementation Result:
181
- ${implResult}
182
- ${worktreeInfo}
183
- ## Instructions
184
- 1. Verify each plan step was completed
185
- 2. Check for any missed items — use \`ask_agent\` / \`spawn_agent\` for specialized review if needed
186
- 3. Note any concerns or improvements needed
187
- 4. Provide a pass/fail verdict with reasoning
188
- 5. Output a concise audit summary in markdown
189
- ${toolContext(ctx)}
496
+ return;
497
+ }
190
498
 
191
- Output ONLY the audit summary.`;
499
+ // Rework transition back to working with incremented attempt
500
+ try {
501
+ await transitionDispatch(
502
+ dispatch.issueIdentifier,
503
+ "auditing",
504
+ "working",
505
+ { attempt: nextAttempt },
506
+ configPath,
507
+ );
508
+ } catch (err) {
509
+ if (err instanceof TransitionError) {
510
+ api.logger.warn(`${TAG} CAS failed for rework transition: ${err.message}`);
511
+ return;
512
+ }
513
+ throw err;
514
+ }
192
515
 
193
- await emit(ctx, { type: "action", action: "Auditing", parameter: ctx.issue.identifier });
516
+ const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
517
+ await linearApi.createComment(
518
+ dispatch.issueId,
519
+ `## Audit Failed — Rework\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Reworking: addressing gaps above.*`,
520
+ ).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
194
521
 
195
- const result = await runAgent({
196
- api: ctx.api,
197
- agentId: ctx.agentId,
198
- sessionId: `linear-audit-${ctx.agentSessionId}`,
199
- message,
200
- timeoutMs: 5 * 60_000,
522
+ api.logger.info(`${TAG} audit FAILED rework attempt ${nextAttempt}/${maxAttempts + 1}`);
201
523
 
524
+ await notify("audit_fail", {
525
+ identifier: dispatch.issueIdentifier,
526
+ title: dispatch.issueIdentifier,
527
+ status: "working",
528
+ attempt: nextAttempt,
529
+ verdict: { pass: false, gaps: verdict.gaps },
202
530
  });
203
531
 
204
- const auditSummary = result.success ? result.output : `Audit failed: ${result.output.slice(0, 500)}`;
205
-
206
- await ctx.linearApi.createComment(
207
- ctx.issue.id,
208
- `## Audit Report\n\n${auditSummary}`,
532
+ // The webhook handler or dispatch service should re-spawn a worker
533
+ // with the rework context. Log emitted for observability.
534
+ api.logger.info(
535
+ `${TAG} dispatch is back in "working" state (attempt ${nextAttempt}). ` +
536
+ `Orchestrator should re-spawn worker with gaps: ${verdict.gaps.join(", ")}`,
209
537
  );
538
+ }
210
539
 
211
- await emit(ctx, {
212
- type: "response",
213
- body: `Completed work on ${ctx.issue.identifier}. Plan, implementation, and audit posted as comments.`,
540
+ // ---------------------------------------------------------------------------
541
+ // Worker phase (called by handleDispatch in webhook.ts)
542
+ // ---------------------------------------------------------------------------
543
+
544
+ /**
545
+ * Spawn the worker agent for a dispatch.
546
+ * Transitions dispatched→working, builds task, runs agent, then triggers audit.
547
+ *
548
+ * This is the main entry point for the v2 pipeline — replaces runFullPipeline.
549
+ */
550
+ export async function spawnWorker(
551
+ hookCtx: HookContext,
552
+ dispatch: ActiveDispatch,
553
+ opts?: { gaps?: string[] },
554
+ ): Promise<void> {
555
+ const { api, linearApi, pluginConfig, configPath } = hookCtx;
556
+ const TAG = `[${dispatch.issueIdentifier}]`;
557
+
558
+ // Transition dispatched → working (first run) — skip if already working (rework)
559
+ if (dispatch.status === "dispatched") {
560
+ try {
561
+ await transitionDispatch(
562
+ dispatch.issueIdentifier,
563
+ "dispatched",
564
+ "working",
565
+ undefined,
566
+ configPath,
567
+ );
568
+ } catch (err) {
569
+ if (err instanceof TransitionError) {
570
+ api.logger.warn(`${TAG} CAS failed for worker spawn: ${err.message}`);
571
+ return;
572
+ }
573
+ throw err;
574
+ }
575
+ }
576
+
577
+ // Fetch fresh issue details
578
+ const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
579
+ const issue: IssueContext = {
580
+ id: dispatch.issueId,
581
+ identifier: dispatch.issueIdentifier,
582
+ title: issueDetails?.title ?? dispatch.issueIdentifier,
583
+ description: issueDetails?.description,
584
+ };
585
+
586
+ // Build worker prompt from YAML templates
587
+ const workerPrompt = buildWorkerTask(issue, dispatch.worktreePath, {
588
+ attempt: dispatch.attempt,
589
+ gaps: opts?.gaps,
590
+ pluginConfig,
214
591
  });
215
- }
216
592
 
217
- // ── Full Pipeline ─────────────────────────────────────────────────
593
+ const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;
594
+
595
+ // Register session mapping for agent_end hook lookup
596
+ await registerSessionMapping(workerSessionId, {
597
+ dispatchId: dispatch.issueIdentifier,
598
+ phase: "worker",
599
+ attempt: dispatch.attempt,
600
+ }, configPath);
218
601
 
219
- export async function runFullPipeline(ctx: PipelineContext): Promise<void> {
220
- // Register active session so tools (code_run) can resolve it
221
- setActiveSession({
222
- agentSessionId: ctx.agentSessionId,
223
- issueIdentifier: ctx.issue.identifier,
224
- issueId: ctx.issue.id,
225
- agentId: ctx.agentId,
226
- startedAt: Date.now(),
602
+ await hookCtx.notify("working", {
603
+ identifier: dispatch.issueIdentifier,
604
+ title: issue.title,
605
+ status: "working",
606
+ attempt: dispatch.attempt,
227
607
  });
228
608
 
229
- try {
230
- // Stage 1: Plan
231
- const plan = await runPlannerStage(ctx);
232
- if (!plan) {
233
- clearActiveSession(ctx.issue.id);
234
- return;
235
- }
609
+ api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
236
610
 
237
- // Pipeline pauses here — user must reply to approve.
238
- // The "prompted" / "created" webhook will call resumePipeline().
239
- // Active session stays registered until resume completes.
240
- } catch (err) {
241
- clearActiveSession(ctx.issue.id);
242
- ctx.api.logger.error(`Pipeline error: ${err}`);
243
- await emit(ctx, { type: "error", body: `Pipeline failed: ${String(err).slice(0, 500)}` });
611
+ const result = await runAgent({
612
+ api,
613
+ agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
614
+ sessionId: workerSessionId,
615
+ message: `${workerPrompt.system}\n\n${workerPrompt.task}`,
616
+ timeoutMs: (pluginConfig?.codexTimeoutMs as number) ?? 10 * 60_000,
617
+ });
618
+
619
+ // runAgent returns inline — trigger audit directly.
620
+ // Re-read dispatch state since it may have changed during worker run.
621
+ const freshState = await readDispatchState(configPath);
622
+ const freshDispatch = getActiveDispatch(freshState, dispatch.issueIdentifier);
623
+ if (!freshDispatch) {
624
+ api.logger.warn(`${TAG} dispatch disappeared during worker run — skipping audit`);
625
+ return;
244
626
  }
627
+
628
+ api.logger.info(`${TAG} worker completed (success=${result.success}, ${result.output.length} chars) — triggering audit`);
629
+
630
+ await triggerAudit(hookCtx, freshDispatch, {
631
+ success: result.success,
632
+ output: result.output,
633
+ }, workerSessionId);
245
634
  }
246
635
 
247
- export async function resumePipeline(ctx: PipelineContext, plan: string): Promise<void> {
248
- // Register (or update) active session for tool resolution
249
- setActiveSession({
250
- agentSessionId: ctx.agentSessionId,
251
- issueIdentifier: ctx.issue.identifier,
252
- issueId: ctx.issue.id,
253
- agentId: ctx.agentId,
254
- startedAt: Date.now(),
255
- });
636
+ // ---------------------------------------------------------------------------
637
+ // Exports for backward compatibility (v1 pipeline)
638
+ // ---------------------------------------------------------------------------
256
639
 
257
- try {
258
- // Stage 2: Implement
259
- const implResult = await runImplementorStage(ctx, plan);
260
- if (!implResult) return;
640
+ // Re-export v1 types and functions that other files may still use
641
+ export type { Tier } from "./dispatch-state.js";
261
642
 
262
- // Stage 3: Audit
263
- await runAuditorStage(ctx, plan, implResult);
264
- } catch (err) {
265
- ctx.api.logger.error(`Pipeline error: ${err}`);
266
- await emit(ctx, { type: "error", body: `Pipeline failed: ${String(err).slice(0, 500)}` });
267
- } finally {
268
- clearActiveSession(ctx.issue.id);
269
- }
643
+ export interface PipelineContext {
644
+ api: OpenClawPluginApi;
645
+ linearApi: LinearAgentApi;
646
+ agentSessionId: string;
647
+ agentId: string;
648
+ issue: IssueContext;
649
+ promptContext?: unknown;
650
+ worktreePath?: string | null;
651
+ codexBranch?: string | null;
652
+ tier?: Tier;
653
+ model?: string;
270
654
  }