@calltelemetry/openclaw-linear 0.4.1 → 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,499 +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
- import type { Tier } from "./dispatch-state.js";
6
- import { runCodex } from "./codex-tool.js";
7
- import { runClaude } from "./claude-tool.js";
8
- import { runGemini } from "./gemini-tool.js";
9
- import { resolveCodingBackend, loadCodingConfig, type CodingBackend } from "./code-tool.js";
10
- import type { CliResult } from "./cli-shared.js";
11
-
12
- export interface PipelineContext {
13
- api: OpenClawPluginApi;
14
- linearApi: LinearAgentApi;
15
- agentSessionId: string;
16
- agentId: string;
17
- issue: {
18
- id: string;
19
- identifier: string;
20
- title: string;
21
- description?: string | null;
22
- };
23
- promptContext?: unknown;
24
- /** Populated by implementor stage if Codex creates a worktree */
25
- worktreePath?: string | null;
26
- /** Codex branch name, e.g. codex/UAT-123 */
27
- codexBranch?: string | null;
28
- /** Complexity tier selected by tier assessment */
29
- tier?: Tier;
30
- /** Tier model ID — for display/tracking only, NOT passed to coding CLI */
31
- model?: string;
32
- }
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";
33
37
 
34
38
  // ---------------------------------------------------------------------------
35
- // Helpers
39
+ // Prompt loading
36
40
  // ---------------------------------------------------------------------------
37
41
 
38
- function emit(ctx: PipelineContext, content: ActivityContent): Promise<void> {
39
- return ctx.linearApi.emitActivity(ctx.agentSessionId, content).catch((err) => {
40
- ctx.api.logger.error(`[${ctx.issue.identifier}] emit failed: ${err}`);
41
- });
42
+ interface PromptTemplates {
43
+ worker: { system: string; task: string };
44
+ audit: { system: string; task: string };
45
+ rework: { addendum: string };
42
46
  }
43
47
 
44
- /** Resolve the agent's model string from config for logging/display. */
45
- function resolveAgentModel(api: OpenClawPluginApi, agentId: string): string {
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
+
46
67
  try {
47
- const config = (api as any).runtime?.config?.getCachedConfig?.() ?? {};
48
- const agents = config?.agents?.list as Array<Record<string, any>> | undefined;
49
- const entry = agents?.find((a) => a.id === agentId);
50
- const modelRef: string =
51
- entry?.model?.primary ??
52
- config?.agents?.defaults?.model?.primary ??
53
- "unknown";
54
- // Strip provider prefix for display: "openrouter/moonshotai/kimi-k2.5" → "kimi-k2.5"
55
- const parts = modelRef.split("/");
56
- return parts.length > 1 ? parts.slice(1).join("/") : modelRef;
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
+ };
57
89
  } catch {
58
- return "unknown";
90
+ _cachedPrompts = DEFAULT_PROMPTS;
59
91
  }
92
+
93
+ return _cachedPrompts;
60
94
  }
61
95
 
62
- function elapsed(startMs: number): string {
63
- const sec = ((Date.now() - startMs) / 1000).toFixed(1);
64
- return `${sec}s`;
96
+ /** Clear prompt cache (for testing or after config change) */
97
+ export function clearPromptCache(): void {
98
+ _cachedPrompts = null;
65
99
  }
66
100
 
67
- function toolContext(ctx: PipelineContext): string {
68
- const lines = [
69
- `\n## code_run Tool`,
70
- `When calling \`code_run\`, pass these parameters:`,
71
- ];
72
- lines.push(`- \`prompt\`: describe what to implement (be specific — file paths, function names, expected behavior)`);
73
- if (ctx.worktreePath) {
74
- lines.push(`- \`workingDir\`: \`"${ctx.worktreePath}"\``);
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);
75
105
  }
76
- // Don't suggest model override — each coding CLI uses its own configured model
77
- lines.push(`Progress streams to Linear automatically. The worktree is an isolated git branch for this issue.`);
78
- return lines.join("\n");
106
+ return result;
79
107
  }
80
108
 
81
- const TAG = (ctx: PipelineContext) => `Pipeline [${ctx.issue.identifier}]`;
82
-
83
109
  // ---------------------------------------------------------------------------
84
- // Stage 1: Planner
110
+ // Task builders
85
111
  // ---------------------------------------------------------------------------
86
112
 
87
- export async function runPlannerStage(ctx: PipelineContext): Promise<string | null> {
88
- const t0 = Date.now();
89
- const agentModel = resolveAgentModel(ctx.api, ctx.agentId);
113
+ export interface IssueContext {
114
+ id: string;
115
+ identifier: string;
116
+ title: string;
117
+ description?: string | null;
118
+ }
90
119
 
91
- ctx.api.logger.info(`${TAG(ctx)} stage 1/3: planner starting (agent=${ctx.agentId}, model=${agentModel})`);
92
- await emit(ctx, {
93
- type: "thought",
94
- body: `[1/3 Plan] Analyzing ${ctx.issue.identifier} with ${ctx.agentId} (${agentModel})...`,
95
- });
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
+ };
139
+
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
+ }
144
+
145
+ return {
146
+ system: renderTemplate(prompts.worker.system, vars),
147
+ task,
148
+ };
149
+ }
150
+
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
+ };
169
+
170
+ return {
171
+ system: renderTemplate(prompts.audit.system, vars),
172
+ task: renderTemplate(prompts.audit.task, vars),
173
+ };
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Verdict parsing
178
+ // ---------------------------------------------------------------------------
96
179
 
97
- const issueDetails = await ctx.linearApi.getIssueDetails(ctx.issue.id).catch(() => null);
180
+ export interface AuditVerdict {
181
+ pass: boolean;
182
+ criteria: string[];
183
+ gaps: string[];
184
+ testResults: string;
185
+ }
98
186
 
99
- const description = issueDetails?.description ?? ctx.issue.description ?? "(no description)";
100
- const comments = issueDetails?.comments?.nodes ?? [];
101
- const commentSummary = comments
102
- .slice(-5)
103
- .map((c) => `${c.user?.name ?? "Unknown"}: ${c.body}`)
104
- .join("\n");
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
+ }
105
209
 
106
- const message = `You are a planner agent. Analyze this Linear issue and create an implementation plan.
210
+ return null;
211
+ }
107
212
 
108
- ## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
213
+ // ---------------------------------------------------------------------------
214
+ // Hook handlers (called by agent_end hook in index.ts)
215
+ // ---------------------------------------------------------------------------
109
216
 
110
- **Description:**
111
- ${description}
217
+ export interface HookContext {
218
+ api: OpenClawPluginApi;
219
+ linearApi: LinearAgentApi;
220
+ notify: NotifyFn;
221
+ pluginConfig?: Record<string, unknown>;
222
+ configPath?: string;
223
+ }
112
224
 
113
- ${commentSummary ? `**Recent comments:**\n${commentSummary}` : ""}
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
+ }
114
247
 
115
- ${ctx.promptContext ? `**Additional context:**\n${JSON.stringify(ctx.promptContext)}` : ""}
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;
263
+ }
116
264
 
117
- ## Instructions
118
- 1. Analyze the issue thoroughly
119
- 2. Break it into concrete implementation steps
120
- 3. Identify files that need to change
121
- 4. Note any risks or dependencies
122
- 5. Output your plan in markdown format
265
+ api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
123
266
 
124
- 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.
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
+ };
125
275
 
126
- Output ONLY the plan, nothing else.`;
276
+ // Build audit prompt from YAML templates
277
+ const auditPrompt = buildAuditTask(issue, dispatch.worktreePath, pluginConfig);
127
278
 
128
- await emit(ctx, {
129
- type: "action",
130
- action: "Planning",
131
- parameter: `${ctx.issue.identifier} agent: ${ctx.agentId} (${agentModel})`,
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(() => {});
284
+
285
+ await notify("auditing", {
286
+ identifier: dispatch.issueIdentifier,
287
+ title: issue.title,
288
+ status: "auditing",
289
+ attempt: dispatch.attempt,
132
290
  });
133
291
 
134
- const sessionId = `linear-plan-${ctx.agentSessionId}`;
135
- ctx.api.logger.info(`${TAG(ctx)} planner: spawning agent session=${sessionId}`);
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
+ }
308
+
309
+ api.logger.info(`${TAG} spawning audit agent session=${auditSessionId}`);
136
310
 
137
311
  const result = await runAgent({
138
- api: ctx.api,
139
- agentId: ctx.agentId,
140
- sessionId,
141
- message,
312
+ api,
313
+ agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
314
+ sessionId: auditSessionId,
315
+ message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
142
316
  timeoutMs: 5 * 60_000,
143
317
  });
144
318
 
145
- if (!result.success) {
146
- ctx.api.logger.error(`${TAG(ctx)} planner failed after ${elapsed(t0)}: ${result.output.slice(0, 300)}`);
147
- await emit(ctx, {
148
- type: "error",
149
- body: `[1/3 Plan] Failed after ${elapsed(t0)}: ${result.output.slice(0, 400)}`,
150
- });
151
- return null;
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})`);
322
+
323
+ await processVerdict(hookCtx, dispatch, {
324
+ success: result.success,
325
+ output: result.output,
326
+ }, auditSessionId);
327
+ }
328
+
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
+ }
352
+
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
+ }
152
372
  }
153
373
 
154
- const plan = result.output;
155
- ctx.api.logger.info(`${TAG(ctx)} planner completed in ${elapsed(t0)} (${plan.length} chars)`);
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
+ }
156
387
 
157
- // Post plan as a Linear comment
158
- await ctx.linearApi.createComment(
159
- ctx.issue.id,
160
- `## Implementation Plan\n\n${plan}\n\n---\n*Proceeding to implementation...*`,
388
+ api.logger.info(
389
+ `${TAG} audit verdict: ${verdict.pass ? "PASS" : "FAIL"} ` +
390
+ `(criteria: ${verdict.criteria.length}, gaps: ${verdict.gaps.length})`,
161
391
  );
162
392
 
163
- await emit(ctx, {
164
- type: "action",
165
- action: "Plan complete",
166
- parameter: `${ctx.issue.identifier} — ${elapsed(t0)}, moving to implementation`,
167
- });
168
-
169
- return plan;
393
+ if (verdict.pass) {
394
+ await handleAuditPass(hookCtx, dispatch, verdict);
395
+ } else {
396
+ await handleAuditFail(hookCtx, dispatch, verdict);
397
+ }
170
398
  }
171
399
 
172
400
  // ---------------------------------------------------------------------------
173
- // Stage 2: Implementor
401
+ // Verdict handlers
174
402
  // ---------------------------------------------------------------------------
175
- //
176
- // Deterministic: pipeline CODE calls the coding CLI directly.
177
- // The agent model only evaluates results between runs.
178
-
179
- const BACKEND_RUNNERS: Record<
180
- CodingBackend,
181
- (api: OpenClawPluginApi, params: any, pluginConfig?: Record<string, unknown>) => Promise<CliResult>
182
- > = {
183
- codex: runCodex,
184
- claude: runClaude,
185
- gemini: runGemini,
186
- };
187
403
 
188
- export async function runImplementorStage(
189
- ctx: PipelineContext,
190
- plan: string,
191
- ): Promise<string | null> {
192
- const t0 = Date.now();
193
- const agentModel = resolveAgentModel(ctx.api, ctx.agentId);
194
- const pluginConfig = (ctx.api as any).pluginConfig as Record<string, unknown> | undefined;
195
-
196
- // Resolve coding backend from config (coding-tools.json)
197
- const codingConfig = loadCodingConfig();
198
- const backend = resolveCodingBackend(codingConfig);
199
- const runner = BACKEND_RUNNERS[backend];
200
- const backendName = backend.charAt(0).toUpperCase() + backend.slice(1);
201
-
202
- ctx.api.logger.info(
203
- `${TAG(ctx)} stage 2/3: implementor starting ` +
204
- `(coding_cli=${backendName}, tier=${ctx.tier ?? "unknown"}, ` +
205
- `worktree=${ctx.worktreePath ?? "default"}, ` +
206
- `eval_agent=${ctx.agentId}, eval_model=${agentModel})`,
207
- );
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}]`;
208
411
 
209
- await emit(ctx, {
210
- type: "thought",
211
- body: `[2/3 Implement] Starting ${backendName} CLI → ${ctx.worktreePath ?? "default workspace"}`,
212
- });
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;
421
+ }
213
422
 
214
- // Build the implementation prompt for the coding CLI
215
- const codePrompt = [
216
- `Implement the following plan for issue ${ctx.issue.identifier} — ${ctx.issue.title}.`,
217
- ``,
218
- `## Plan`,
219
- plan,
220
- ``,
221
- `## Instructions`,
222
- `- Follow the plan step by step`,
223
- `- Create commits for each logical change`,
224
- `- Run tests if the project has them`,
225
- `- Stay within scope of the plan`,
226
- ].join("\n");
227
-
228
- await emit(ctx, {
229
- type: "action",
230
- action: `Running ${backendName}`,
231
- parameter: `${ctx.tier ?? "unknown"} tier — worktree: ${ctx.worktreePath ?? "default"}`,
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: [] },
232
446
  });
233
447
 
234
- // Call the coding CLI directly — deterministic, not LLM choice.
235
- // NOTE: Do NOT pass ctx.model here. The tier model (e.g. anthropic/claude-sonnet-4-6)
236
- // is for tracking/display only. Each coding CLI uses its own configured model.
237
- ctx.api.logger.info(`${TAG(ctx)} implementor: invoking ${backendName} CLI (no model override — CLI uses its own config)`);
238
- const cliStart = Date.now();
239
-
240
- const codeResult = await runner(ctx.api, {
241
- prompt: codePrompt,
242
- workingDir: ctx.worktreePath ?? undefined,
243
- timeoutMs: 10 * 60_000,
244
- }, pluginConfig);
245
-
246
- const cliElapsed = elapsed(cliStart);
247
-
248
- if (!codeResult.success) {
249
- ctx.api.logger.warn(
250
- `${TAG(ctx)} implementor: ${backendName} CLI failed after ${cliElapsed} — ` +
251
- `error: ${codeResult.error ?? "unknown"}, output: ${codeResult.output.slice(0, 300)}`,
252
- );
253
- await emit(ctx, {
254
- type: "error",
255
- body: `[2/3 Implement] ${backendName} failed after ${cliElapsed}: ${(codeResult.error ?? codeResult.output).slice(0, 400)}`,
256
- });
448
+ clearActiveSession(dispatch.issueId);
449
+ }
257
450
 
258
- // Ask the agent to evaluate the failure
259
- ctx.api.logger.info(`${TAG(ctx)} implementor: spawning ${ctx.agentId} (${agentModel}) to evaluate failure`);
260
- await emit(ctx, {
261
- type: "action",
262
- action: "Evaluating failure",
263
- parameter: `${ctx.agentId} (${agentModel}) analyzing ${backendName} error`,
264
- });
451
+ async function handleAuditFail(
452
+ hookCtx: HookContext,
453
+ dispatch: ActiveDispatch,
454
+ verdict: AuditVerdict,
455
+ ): Promise<void> {
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
+ }
265
478
 
266
- const evalResult = await runAgent({
267
- api: ctx.api,
268
- agentId: ctx.agentId,
269
- sessionId: `linear-impl-eval-${ctx.agentSessionId}`,
270
- message: `${backendName} failed to implement the plan for ${ctx.issue.identifier}.\n\n## Plan\n${plan}\n\n## ${backendName} Output\n${codeResult.output.slice(0, 3000)}\n\n## Error\n${codeResult.error ?? "unknown"}\n\nAnalyze the failure. Summarize what went wrong and suggest next steps. Be concise.`,
271
- timeoutMs: 2 * 60_000,
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}`));
484
+
485
+ api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
486
+
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 },
272
494
  });
273
495
 
274
- const failureSummary = evalResult.success
275
- ? evalResult.output
276
- : `Implementation failed and evaluation also failed: ${codeResult.output.slice(0, 500)}`;
496
+ return;
497
+ }
277
498
 
278
- await ctx.linearApi.createComment(
279
- ctx.issue.id,
280
- `## Implementation Failed\n\n**Backend:** ${backendName} (ran for ${cliElapsed})\n**Tier:** ${ctx.tier ?? "unknown"}\n\n${failureSummary}`,
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,
281
507
  );
282
-
283
- return null;
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;
284
514
  }
285
515
 
286
- ctx.api.logger.info(`${TAG(ctx)} implementor: ${backendName} CLI completed in ${cliElapsed} (${codeResult.output.length} chars output)`);
287
-
288
- // Ask the agent to evaluate the result
289
- const evalMessage = [
290
- `${backendName} completed implementation for ${ctx.issue.identifier}. Evaluate the result.`,
291
- ``,
292
- `## Original Plan`,
293
- plan,
294
- ``,
295
- `## ${backendName} Output`,
296
- codeResult.output.slice(0, 5000),
297
- ``,
298
- `## Worktree`,
299
- `Path: ${ctx.worktreePath ?? "default"}`,
300
- `Branch: ${ctx.codexBranch ?? "unknown"}`,
301
- ``,
302
- `Summarize what was implemented, any issues found, and whether the plan was fully executed. Be concise.`,
303
- ].join("\n");
304
-
305
- ctx.api.logger.info(`${TAG(ctx)} implementor: spawning ${ctx.agentId} (${agentModel}) to evaluate results`);
306
- await emit(ctx, {
307
- type: "action",
308
- action: "Evaluating results",
309
- parameter: `${ctx.agentId} (${agentModel}) reviewing ${backendName} output`,
310
- });
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}`));
311
521
 
312
- const evalStart = Date.now();
313
- const evalResult = await runAgent({
314
- api: ctx.api,
315
- agentId: ctx.agentId,
316
- sessionId: `linear-impl-eval-${ctx.agentSessionId}`,
317
- message: evalMessage,
318
- timeoutMs: 3 * 60_000,
319
- });
320
-
321
- const summary = evalResult.success
322
- ? evalResult.output
323
- : `Implementation completed but evaluation failed. ${backendName} output:\n${codeResult.output.slice(0, 2000)}`;
324
-
325
- ctx.api.logger.info(
326
- `${TAG(ctx)} implementor: evaluation ${evalResult.success ? "succeeded" : "failed"} in ${elapsed(evalStart)}, ` +
327
- `total stage time: ${elapsed(t0)}`,
328
- );
522
+ api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
329
523
 
330
- await emit(ctx, {
331
- type: "action",
332
- action: "Implementation complete",
333
- parameter: `${backendName} ${cliElapsed} + eval ${elapsed(evalStart)} = ${elapsed(t0)} total`,
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 },
334
530
  });
335
531
 
336
- return summary;
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(", ")}`,
537
+ );
337
538
  }
338
539
 
339
540
  // ---------------------------------------------------------------------------
340
- // Stage 3: Auditor
541
+ // Worker phase (called by handleDispatch in webhook.ts)
341
542
  // ---------------------------------------------------------------------------
342
543
 
343
- export async function runAuditorStage(
344
- ctx: PipelineContext,
345
- plan: string,
346
- implResult: string,
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[] },
347
554
  ): Promise<void> {
348
- const t0 = Date.now();
349
- const agentModel = resolveAgentModel(ctx.api, ctx.agentId);
350
-
351
- ctx.api.logger.info(
352
- `${TAG(ctx)} stage 3/3: auditor starting (agent=${ctx.agentId}, model=${agentModel})`,
353
- );
354
- await emit(ctx, {
355
- type: "thought",
356
- body: `[3/3 Audit] Reviewing implementation with ${ctx.agentId} (${agentModel})...`,
357
- });
358
-
359
- const worktreeInfo = ctx.worktreePath
360
- ? `\n## Worktree\nCode changes are at: \`${ctx.worktreePath}\` (branch: \`${ctx.codexBranch ?? "unknown"}\`)\n`
361
- : "";
362
-
363
- const message = `You are an auditor. Review this implementation against the original plan.
364
-
365
- ## Issue: ${ctx.issue.identifier} — ${ctx.issue.title}
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
+ }
366
576
 
367
- ## Original Plan:
368
- ${plan}
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
+ };
369
585
 
370
- ## Implementation Result:
371
- ${implResult}
372
- ${worktreeInfo}
373
- ## Instructions
374
- 1. Verify each plan step was completed
375
- 2. Check for any missed items — use \`ask_agent\` / \`spawn_agent\` for specialized review if needed
376
- 3. Note any concerns or improvements needed
377
- 4. Provide a pass/fail verdict with reasoning
378
- 5. Output a concise audit summary in markdown
379
- ${toolContext(ctx)}
586
+ // Build worker prompt from YAML templates
587
+ const workerPrompt = buildWorkerTask(issue, dispatch.worktreePath, {
588
+ attempt: dispatch.attempt,
589
+ gaps: opts?.gaps,
590
+ pluginConfig,
591
+ });
380
592
 
381
- Output ONLY the audit summary.`;
593
+ const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;
382
594
 
383
- const sessionId = `linear-audit-${ctx.agentSessionId}`;
384
- ctx.api.logger.info(`${TAG(ctx)} auditor: spawning agent session=${sessionId}`);
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);
385
601
 
386
- await emit(ctx, {
387
- type: "action",
388
- action: "Auditing",
389
- parameter: `${ctx.issue.identifier} — agent: ${ctx.agentId} (${agentModel})`,
602
+ await hookCtx.notify("working", {
603
+ identifier: dispatch.issueIdentifier,
604
+ title: issue.title,
605
+ status: "working",
606
+ attempt: dispatch.attempt,
390
607
  });
391
608
 
609
+ api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
610
+
392
611
  const result = await runAgent({
393
- api: ctx.api,
394
- agentId: ctx.agentId,
395
- sessionId,
396
- message,
397
- timeoutMs: 5 * 60_000,
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,
398
617
  });
399
618
 
400
- const auditSummary = result.success
401
- ? result.output
402
- : `Audit failed: ${result.output.slice(0, 500)}`;
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;
626
+ }
403
627
 
404
- ctx.api.logger.info(
405
- `${TAG(ctx)} auditor: ${result.success ? "completed" : "failed"} in ${elapsed(t0)} (${auditSummary.length} chars)`,
406
- );
628
+ api.logger.info(`${TAG} worker completed (success=${result.success}, ${result.output.length} chars) — triggering audit`);
407
629
 
408
- await ctx.linearApi.createComment(
409
- ctx.issue.id,
410
- `## Audit Report\n\n${auditSummary}`,
411
- );
412
-
413
- await emit(ctx, {
414
- type: "response",
415
- body: `[3/3 Audit] ${result.success ? "Complete" : "Failed"} (${elapsed(t0)}). ` +
416
- `All stages done for ${ctx.issue.identifier}. Plan, implementation, and audit posted as comments.`,
417
- });
630
+ await triggerAudit(hookCtx, freshDispatch, {
631
+ success: result.success,
632
+ output: result.output,
633
+ }, workerSessionId);
418
634
  }
419
635
 
420
636
  // ---------------------------------------------------------------------------
421
- // Full Pipeline
637
+ // Exports for backward compatibility (v1 pipeline)
422
638
  // ---------------------------------------------------------------------------
423
- //
424
- // Runs all three stages sequentially: plan → implement → audit.
425
- // Assignment is the trigger AND the approval — no pause between stages.
426
- // Each stage's result feeds into the next. If any stage fails, the
427
- // pipeline stops and reports the error.
428
-
429
- export async function runFullPipeline(ctx: PipelineContext): Promise<void> {
430
- const t0 = Date.now();
431
- const agentModel = resolveAgentModel(ctx.api, ctx.agentId);
432
- const codingConfig = loadCodingConfig();
433
- const codingBackend = resolveCodingBackend(codingConfig);
434
-
435
- ctx.api.logger.info(
436
- `${TAG(ctx)} === PIPELINE START === ` +
437
- `agent=${ctx.agentId}, agent_model=${agentModel}, ` +
438
- `coding_cli=${codingBackend}, tier=${ctx.tier ?? "unknown"}, ` +
439
- `worktree=${ctx.worktreePath ?? "none"}, ` +
440
- `branch=${ctx.codexBranch ?? "none"}, ` +
441
- `session=${ctx.agentSessionId}`,
442
- );
443
-
444
- // Register active session so tools (code_run) can resolve it
445
- setActiveSession({
446
- agentSessionId: ctx.agentSessionId,
447
- issueIdentifier: ctx.issue.identifier,
448
- issueId: ctx.issue.id,
449
- agentId: ctx.agentId,
450
- startedAt: Date.now(),
451
- });
452
639
 
453
- await emit(ctx, {
454
- type: "thought",
455
- body: `Pipeline started for ${ctx.issue.identifier} — ` +
456
- `agent: ${ctx.agentId} (${agentModel}), ` +
457
- `coding: ${codingBackend}, ` +
458
- `tier: ${ctx.tier ?? "unknown"}`,
459
- });
460
-
461
- try {
462
- // Stage 1: Plan
463
- const plan = await runPlannerStage(ctx);
464
- if (!plan) {
465
- ctx.api.logger.error(`${TAG(ctx)} planner produced no plan — aborting after ${elapsed(t0)}`);
466
- await emit(ctx, {
467
- type: "error",
468
- body: `Pipeline aborted — planning stage failed after ${elapsed(t0)}. No plan produced.`,
469
- });
470
- return;
471
- }
640
+ // Re-export v1 types and functions that other files may still use
641
+ export type { Tier } from "./dispatch-state.js";
472
642
 
473
- // Stage 2: Implement
474
- const implResult = await runImplementorStage(ctx, plan);
475
- if (!implResult) {
476
- ctx.api.logger.error(`${TAG(ctx)} implementor failed — aborting after ${elapsed(t0)}`);
477
- await emit(ctx, {
478
- type: "error",
479
- body: `Pipeline aborted — implementation stage failed after ${elapsed(t0)}.`,
480
- });
481
- return;
482
- }
483
-
484
- // Stage 3: Audit
485
- await runAuditorStage(ctx, plan, implResult);
486
-
487
- ctx.api.logger.info(
488
- `${TAG(ctx)} === PIPELINE COMPLETE === total time: ${elapsed(t0)}`,
489
- );
490
- } catch (err) {
491
- ctx.api.logger.error(`${TAG(ctx)} === PIPELINE ERROR === after ${elapsed(t0)}: ${err}`);
492
- await emit(ctx, {
493
- type: "error",
494
- body: `Pipeline crashed after ${elapsed(t0)}: ${String(err).slice(0, 400)}`,
495
- });
496
- } finally {
497
- clearActiveSession(ctx.issue.id);
498
- }
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;
499
654
  }