@calltelemetry/openclaw-linear 0.5.0 → 0.5.2

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/index.ts CHANGED
@@ -196,15 +196,21 @@ export default function register(api: OpenClawPluginApi) {
196
196
  try {
197
197
  const raw = execFileSync(bin, ["--version"], {
198
198
  encoding: "utf8",
199
- timeout: 5_000,
199
+ timeout: 15_000,
200
200
  env: { ...process.env, CLAUDECODE: undefined } as any,
201
201
  }).trim();
202
202
  cliChecks[name] = raw || "unknown";
203
203
  } catch {
204
- cliChecks[name] = "not found";
205
- api.logger.warn(
206
- `${name} CLI not found at ${bin}. The ${name}_run tool will fail. Install with: ${installCmd}`,
207
- );
204
+ // Fallback: check if the file exists (execFileSync can fail in worker contexts)
205
+ try {
206
+ require("node:fs").accessSync(bin, require("node:fs").constants.X_OK);
207
+ cliChecks[name] = "installed (version check skipped)";
208
+ } catch {
209
+ cliChecks[name] = "not found";
210
+ api.logger.warn(
211
+ `${name} CLI not found at ${bin}. The ${name}_run tool will fail. Install with: ${installCmd}`,
212
+ );
213
+ }
208
214
  }
209
215
  }
210
216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/prompts.yaml CHANGED
@@ -5,13 +5,19 @@
5
5
  #
6
6
  # Edit these to customize worker/audit behavior without rebuilding the plugin.
7
7
  # Override path via `promptsPath` in plugin config.
8
+ #
9
+ # Access model:
10
+ # Zoe (orchestrator) — linearis READ ONLY (issues read/list/search)
11
+ # Worker — NO linearis access. Return text output only.
12
+ # Auditor — linearis READ + WRITE (can update status, close, comment)
8
13
 
9
14
  worker:
10
15
  system: |
11
- You are implementing a Linear issue. Your job is to plan and code the solution.
12
- Post an implementation summary as a Linear comment when done.
13
- DO NOT mark the issue as Done that is handled by the audit system.
14
- DO NOT attempt to change the issue status or labels.
16
+ You are a coding worker implementing a Linear issue. Your ONLY job is to write code and return a text summary.
17
+ You do NOT have access to linearis or any Linear issue management tools.
18
+ Do NOT attempt to update, close, comment on, or modify the Linear issue in any way.
19
+ Do NOT mark the issue as Done — the audit system handles all issue lifecycle.
20
+ Just write code and return your implementation summary as text.
15
21
  task: |
16
22
  Implement issue {{identifier}}: {{title}}
17
23
 
@@ -25,15 +31,22 @@ worker:
25
31
  2. Plan your approach
26
32
  3. Implement the solution in the worktree
27
33
  4. Run tests to verify your changes
28
- 5. Post an implementation summary as a comment on the Linear issue
29
- 6. Include what was changed, what tests were run, and any notes
34
+ 5. Return a text summary of what you changed, what tests you ran, and any notes
35
+
36
+ Your text output will be captured automatically. Do NOT use linearis or attempt to post comments.
30
37
 
31
38
  audit:
32
39
  system: |
33
- You are an independent auditor. Your job is to verify that work was completed correctly.
40
+ You are an independent auditor. Your job is to verify that work was completed correctly
41
+ and then update the Linear issue accordingly.
34
42
  The Linear issue body is the SOURCE OF TRUTH for what "done" means.
35
- Worker comments are secondary evidence of what was attempted.
43
+ Worker output is secondary evidence of what was attempted.
36
44
  You must be thorough and objective. Do not rubber-stamp.
45
+
46
+ You have WRITE access to linearis. After auditing, you are responsible for:
47
+ - Posting an audit summary comment on the issue
48
+ - Updating the issue status if the audit passes
49
+ Use `linearis` CLI via exec for these operations.
37
50
  task: |
38
51
  Audit issue {{identifier}}: {{title}}
39
52
 
@@ -44,12 +57,16 @@ audit:
44
57
 
45
58
  Checklist:
46
59
  1. Identify ALL acceptance criteria from the issue body
47
- 2. Read worker comments on the issue (use linearis)
60
+ 2. Read worker comments: `linearis issues read {{identifier}}`
48
61
  3. Verify each acceptance criterion is addressed in the code
49
62
  4. Run tests in the worktree — verify they pass
50
63
  5. Check test coverage if expectations are stated in the issue
51
64
  6. Review the code diff for quality and correctness
52
65
 
66
+ After auditing:
67
+ - Post your audit findings as a comment: `linearis comments create {{identifier}} --body "..."`
68
+ - If PASS: update status: `linearis issues update {{identifier}} --status "Done"`
69
+
53
70
  You MUST return a JSON verdict as the last line of your response:
54
71
  {"pass": true/false, "criteria": ["list of criteria found"], "gaps": ["list of unmet criteria"], "testResults": "summary of test output"}
55
72
 
@@ -59,3 +76,4 @@ rework:
59
76
  {{gaps}}
60
77
 
61
78
  Address these specific issues in your rework. Focus on the gaps listed above.
79
+ Remember: you do NOT have linearis access. Just fix the code and return a text summary.
package/src/agent.ts CHANGED
@@ -1,7 +1,36 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync } from "node:fs";
2
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
5
  import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
4
6
 
7
+ // ---------------------------------------------------------------------------
8
+ // Agent directory resolution (config-based, not ext API which ignores agentId)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ interface AgentDirs {
12
+ workspaceDir: string;
13
+ agentDir: string;
14
+ }
15
+
16
+ function resolveAgentDirs(agentId: string, config: Record<string, any>): AgentDirs {
17
+ const home = process.env.HOME ?? "/home/claw";
18
+ const agentList = config?.agents?.list as Array<Record<string, any>> | undefined;
19
+ const agentEntry = agentList?.find((a) => a.id === agentId);
20
+
21
+ // Workspace: agent-specific override → agents.defaults.workspace → fallback
22
+ const workspaceDir = agentEntry?.workspace
23
+ ?? config?.agents?.defaults?.workspace
24
+ ?? join(home, ".openclaw", "workspace");
25
+
26
+ // Agent runtime dir: always ~/.openclaw/agents/{agentId}/agent
27
+ // (matches OpenClaw's internal structure)
28
+ const agentDir = join(home, ".openclaw", "agents", agentId, "agent");
29
+ mkdirSync(agentDir, { recursive: true });
30
+
31
+ return { workspaceDir, agentDir };
32
+ }
33
+
5
34
  // Import extensionAPI for embedded agent runner (internal, not in public SDK)
6
35
  let _extensionAPI: typeof import("/home/claw/.npm-global/lib/node_modules/openclaw/dist/extensionAPI.js") | null = null;
7
36
  async function getExtensionAPI() {
@@ -66,17 +95,22 @@ async function runEmbedded(
66
95
  ): Promise<AgentRunResult> {
67
96
  const ext = await getExtensionAPI();
68
97
 
69
- const workspaceDir = ext.resolveAgentWorkspaceDir({ agentId });
70
- const sessionFile = ext.resolveSessionFilePath(sessionId);
71
- const agentDir = ext.resolveAgentDir({ agentId });
98
+ // Load config so we can resolve agent dirs and providers correctly.
99
+ const config = await api.runtime.config.loadConfig();
100
+ const configAny = config as Record<string, any>;
101
+
102
+ // Resolve workspace and agent dirs from config (ext API ignores agentId).
103
+ const dirs = resolveAgentDirs(agentId, configAny);
104
+ const { workspaceDir, agentDir } = dirs;
72
105
  const runId = randomUUID();
73
106
 
74
- // Load config so embedded runner can resolve providers, API keys, etc.
75
- const config = await api.runtime.config.loadConfig();
107
+ // Build session file path under the correct agent's sessions directory.
108
+ const sessionsDir = join(agentDir, "sessions");
109
+ try { mkdirSync(sessionsDir, { recursive: true }); } catch {}
110
+ const sessionFile = join(sessionsDir, `${sessionId}.jsonl`);
76
111
 
77
112
  // Resolve model/provider from config — default is anthropic which requires
78
113
  // a separate API key. Our agents use openrouter.
79
- const configAny = config as Record<string, any>;
80
114
  const agentList = configAny?.agents?.list as Array<Record<string, any>> | undefined;
81
115
  const agentEntry = agentList?.find((a) => a.id === agentId);
82
116
  const modelRef: string =
@@ -89,7 +123,7 @@ async function runEmbedded(
89
123
  const provider = slashIdx > 0 ? modelRef.slice(0, slashIdx) : ext.DEFAULT_PROVIDER;
90
124
  const model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
91
125
 
92
- api.logger.info(`Embedded agent run: agent=${agentId} session=${sessionId} runId=${runId} provider=${provider} model=${model}`);
126
+ api.logger.info(`Embedded agent run: agent=${agentId} session=${sessionId} runId=${runId} provider=${provider} model=${model} workspaceDir=${workspaceDir} agentDir=${agentDir}`);
93
127
 
94
128
  const emit = (content: ActivityContent) => {
95
129
  streaming.linearApi.emitActivity(streaming.agentSessionId, content).catch((err) => {
@@ -0,0 +1,265 @@
1
+ /**
2
+ * artifacts.ts — .claw/ per-worktree artifact convention.
3
+ *
4
+ * Provides a standard directory structure for storing artifacts during
5
+ * the lifecycle of a dispatched issue. Any OpenClaw plugin that works
6
+ * with a worktree can write to {worktreePath}/.claw/.
7
+ *
8
+ * Structure:
9
+ * .claw/
10
+ * manifest.json — issue metadata + lifecycle timestamps
11
+ * plan.md — implementation plan
12
+ * worker-{N}.md — worker output per attempt (truncated)
13
+ * audit-{N}.json — audit verdict per attempt
14
+ * log.jsonl — append-only interaction log
15
+ * summary.md — agent-curated final summary
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import type { AuditVerdict } from "./pipeline.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const MAX_ARTIFACT_SIZE = 8192; // 8KB per output file
27
+ const MAX_PREVIEW_SIZE = 500; // For log entry previews
28
+ const MAX_PROMPT_PREVIEW = 200; // For log entry prompt previews
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface ClawManifest {
35
+ issueIdentifier: string;
36
+ issueTitle: string;
37
+ issueId: string;
38
+ tier: string;
39
+ model: string;
40
+ dispatchedAt: string;
41
+ worktreePath: string;
42
+ branch: string;
43
+ attempts: number;
44
+ status: string;
45
+ plugin: string;
46
+ }
47
+
48
+ export interface LogEntry {
49
+ ts: string;
50
+ phase: "worker" | "audit" | "verdict" | "dispatch";
51
+ attempt: number;
52
+ agent: string;
53
+ prompt: string;
54
+ outputPreview: string;
55
+ success: boolean;
56
+ durationMs?: number;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Directory setup
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function clawDir(worktreePath: string): string {
64
+ return join(worktreePath, ".claw");
65
+ }
66
+
67
+ /** Creates .claw/ directory. Returns the path. */
68
+ export function ensureClawDir(worktreePath: string): string {
69
+ const dir = clawDir(worktreePath);
70
+ if (!existsSync(dir)) {
71
+ mkdirSync(dir, { recursive: true });
72
+ }
73
+ return dir;
74
+ }
75
+
76
+ /**
77
+ * Ensure .claw/ is in .gitignore at the worktree root.
78
+ * Appends if not already present. Idempotent.
79
+ */
80
+ export function ensureGitignore(worktreePath: string): void {
81
+ const gitignorePath = join(worktreePath, ".gitignore");
82
+ try {
83
+ const content = existsSync(gitignorePath)
84
+ ? readFileSync(gitignorePath, "utf-8")
85
+ : "";
86
+ if (!content.split("\n").some((line) => line.trim() === ".claw" || line.trim() === ".claw/")) {
87
+ const nl = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
88
+ appendFileSync(gitignorePath, `${nl}.claw/\n`, "utf-8");
89
+ }
90
+ } catch {
91
+ // Best effort — don't block pipeline
92
+ }
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Manifest
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export function writeManifest(worktreePath: string, manifest: ClawManifest): void {
100
+ const dir = ensureClawDir(worktreePath);
101
+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
102
+ }
103
+
104
+ export function readManifest(worktreePath: string): ClawManifest | null {
105
+ try {
106
+ const raw = readFileSync(join(clawDir(worktreePath), "manifest.json"), "utf-8");
107
+ return JSON.parse(raw) as ClawManifest;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ export function updateManifest(worktreePath: string, updates: Partial<ClawManifest>): void {
114
+ const current = readManifest(worktreePath);
115
+ if (!current) return;
116
+ writeManifest(worktreePath, { ...current, ...updates });
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Phase artifacts
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /** Save worker output for a given attempt. Truncated to MAX_ARTIFACT_SIZE. */
124
+ export function saveWorkerOutput(worktreePath: string, attempt: number, output: string): void {
125
+ const dir = ensureClawDir(worktreePath);
126
+ const truncated = output.length > MAX_ARTIFACT_SIZE
127
+ ? output.slice(0, MAX_ARTIFACT_SIZE) + "\n\n--- truncated ---"
128
+ : output;
129
+ writeFileSync(join(dir, `worker-${attempt}.md`), truncated, "utf-8");
130
+ }
131
+
132
+ /** Save a plan (extracted from worker output or provided directly). */
133
+ export function savePlan(worktreePath: string, plan: string): void {
134
+ const dir = ensureClawDir(worktreePath);
135
+ writeFileSync(join(dir, "plan.md"), plan, "utf-8");
136
+ }
137
+
138
+ /** Save audit verdict for a given attempt. */
139
+ export function saveAuditVerdict(worktreePath: string, attempt: number, verdict: AuditVerdict): void {
140
+ const dir = ensureClawDir(worktreePath);
141
+ writeFileSync(join(dir, `audit-${attempt}.json`), JSON.stringify(verdict, null, 2) + "\n", "utf-8");
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Interaction log
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** Append a structured log entry to .claw/log.jsonl. */
149
+ export function appendLog(worktreePath: string, entry: LogEntry): void {
150
+ const dir = ensureClawDir(worktreePath);
151
+ const truncated: LogEntry = {
152
+ ...entry,
153
+ prompt: entry.prompt.slice(0, MAX_PROMPT_PREVIEW),
154
+ outputPreview: entry.outputPreview.slice(0, MAX_PREVIEW_SIZE),
155
+ };
156
+ appendFileSync(join(dir, "log.jsonl"), JSON.stringify(truncated) + "\n", "utf-8");
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Summary
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** Write the final curated summary. */
164
+ export function writeSummary(worktreePath: string, summary: string): void {
165
+ const dir = ensureClawDir(worktreePath);
166
+ writeFileSync(join(dir, "summary.md"), summary, "utf-8");
167
+ }
168
+
169
+ /**
170
+ * Build a markdown summary from all .claw/ artifacts.
171
+ * Used at issue completion to generate a memory-friendly summary.
172
+ */
173
+ export function buildSummaryFromArtifacts(worktreePath: string): string | null {
174
+ const manifest = readManifest(worktreePath);
175
+ if (!manifest) return null;
176
+
177
+ const parts: string[] = [];
178
+ parts.push(`# Dispatch: ${manifest.issueIdentifier} — ${manifest.issueTitle}`);
179
+ parts.push(`**Tier:** ${manifest.tier} | **Status:** ${manifest.status} | **Attempts:** ${manifest.attempts}`);
180
+ parts.push("");
181
+
182
+ // Include plan if exists
183
+ try {
184
+ const plan = readFileSync(join(clawDir(worktreePath), "plan.md"), "utf-8");
185
+ parts.push("## Plan");
186
+ parts.push(plan.slice(0, 2000));
187
+ parts.push("");
188
+ } catch { /* no plan */ }
189
+
190
+ // Include each attempt's worker + audit
191
+ for (let i = 0; i < manifest.attempts; i++) {
192
+ parts.push(`## Attempt ${i}`);
193
+
194
+ // Worker output preview
195
+ try {
196
+ const worker = readFileSync(join(clawDir(worktreePath), `worker-${i}.md`), "utf-8");
197
+ parts.push("### Worker Output");
198
+ parts.push(worker.slice(0, 1500));
199
+ parts.push("");
200
+ } catch { /* no worker output */ }
201
+
202
+ // Audit verdict
203
+ try {
204
+ const raw = readFileSync(join(clawDir(worktreePath), `audit-${i}.json`), "utf-8");
205
+ const verdict = JSON.parse(raw) as AuditVerdict;
206
+ parts.push(`### Audit: ${verdict.pass ? "PASS" : "FAIL"}`);
207
+ if (verdict.criteria.length) parts.push(`**Criteria:** ${verdict.criteria.join(", ")}`);
208
+ if (verdict.gaps.length) parts.push(`**Gaps:** ${verdict.gaps.join(", ")}`);
209
+ if (verdict.testResults) parts.push(`**Tests:** ${verdict.testResults}`);
210
+ parts.push("");
211
+ } catch { /* no audit */ }
212
+ }
213
+
214
+ parts.push("---");
215
+ parts.push(`*Artifacts: ${worktreePath}/.claw/*`);
216
+
217
+ return parts.join("\n");
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Memory integration
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * Write dispatch summary to the orchestrator's memory directory.
226
+ * Auto-indexed by OpenClaw's sqlite+embeddings memory system.
227
+ */
228
+ export function writeDispatchMemory(
229
+ issueIdentifier: string,
230
+ summary: string,
231
+ workspaceDir: string,
232
+ ): void {
233
+ const memDir = join(workspaceDir, "memory");
234
+ if (!existsSync(memDir)) {
235
+ mkdirSync(memDir, { recursive: true });
236
+ }
237
+ writeFileSync(
238
+ join(memDir, `dispatch-${issueIdentifier}.md`),
239
+ summary,
240
+ "utf-8",
241
+ );
242
+ }
243
+
244
+ /**
245
+ * Resolve the orchestrator agent's workspace directory from config.
246
+ * Same config-based approach as resolveAgentDirs in agent.ts.
247
+ */
248
+ export function resolveOrchestratorWorkspace(
249
+ api: any,
250
+ pluginConfig?: Record<string, unknown>,
251
+ ): string {
252
+ const home = process.env.HOME ?? "/home/claw";
253
+ const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
254
+
255
+ try {
256
+ const config = api.runtime.config.loadConfig() as Record<string, any>;
257
+ const agentList = config?.agents?.list as Array<Record<string, any>> | undefined;
258
+ const agentEntry = agentList?.find((a: any) => a.id === agentId);
259
+ return agentEntry?.workspace
260
+ ?? config?.agents?.defaults?.workspace
261
+ ?? join(home, ".openclaw", "workspace");
262
+ } catch {
263
+ return join(home, ".openclaw", "workspace");
264
+ }
265
+ }
@@ -138,7 +138,6 @@ export async function runClaude(
138
138
  if (model ?? pluginConfig?.claudeModel) {
139
139
  args.push("--model", (model ?? pluginConfig?.claudeModel) as string);
140
140
  }
141
- args.push("-C", workingDir);
142
141
  args.push("-p", prompt);
143
142
 
144
143
  api.logger.info(`Claude exec: ${CLAUDE_BIN} ${args.join(" ").slice(0, 200)}...`);
@@ -150,6 +149,7 @@ export async function runClaude(
150
149
 
151
150
  const child = spawn(CLAUDE_BIN, args, {
152
151
  stdio: ["ignore", "pipe", "pipe"],
152
+ cwd: workingDir,
153
153
  env,
154
154
  timeout: 0,
155
155
  });
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
+ import { ensureGitignore } from "./artifacts.js";
5
6
 
6
7
  const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
7
8
  const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
@@ -90,6 +91,7 @@ export function createWorktree(
90
91
  try {
91
92
  // Verify it's a valid git worktree
92
93
  git(["rev-parse", "--git-dir"], worktreePath);
94
+ ensureGitignore(worktreePath);
93
95
  return { path: worktreePath, branch, resumed: true };
94
96
  } catch {
95
97
  // Directory exists but isn't a valid worktree — remove and recreate
@@ -105,11 +107,13 @@ export function createWorktree(
105
107
  if (branchExists) {
106
108
  // Recreate worktree from existing branch — preserves previous work
107
109
  git(["worktree", "add", worktreePath, branch], repo);
110
+ ensureGitignore(worktreePath);
108
111
  return { path: worktreePath, branch, resumed: true };
109
112
  }
110
113
 
111
114
  // Fresh start: new branch off HEAD
112
115
  git(["worktree", "add", "-b", branch, worktreePath], repo);
116
+ ensureGitignore(worktreePath);
113
117
  return { path: worktreePath, branch, resumed: false };
114
118
  }
115
119
 
@@ -57,6 +57,7 @@ export interface ActiveDispatch {
57
57
  workerSessionKey?: string; // session key for current worker sub-agent
58
58
  auditSessionKey?: string; // session key for current audit sub-agent
59
59
  stuckReason?: string; // only set when status === "stuck"
60
+ issueTitle?: string; // for artifact summaries and memory headings
60
61
  }
61
62
 
62
63
  export interface CompletedDispatch {
package/src/pipeline.ts CHANGED
@@ -34,6 +34,16 @@ import {
34
34
  getActiveDispatch,
35
35
  } from "./dispatch-state.js";
36
36
  import { type NotifyFn } from "./notify.js";
37
+ import {
38
+ saveWorkerOutput,
39
+ saveAuditVerdict,
40
+ appendLog,
41
+ updateManifest,
42
+ writeSummary,
43
+ buildSummaryFromArtifacts,
44
+ writeDispatchMemory,
45
+ resolveOrchestratorWorkspace,
46
+ } from "./artifacts.js";
37
47
 
38
48
  // ---------------------------------------------------------------------------
39
49
  // Prompt loading
@@ -264,6 +274,9 @@ export async function triggerAudit(
264
274
 
265
275
  api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
266
276
 
277
+ // Update .claw/ manifest
278
+ try { updateManifest(dispatch.worktreePath, { status: "auditing", attempts: dispatch.attempt }); } catch {}
279
+
267
280
  // Fetch fresh issue details for audit context
268
281
  const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
269
282
  const issue: IssueContext = {
@@ -371,6 +384,15 @@ export async function processVerdict(
371
384
  }
372
385
  }
373
386
 
387
+ // Log audit interaction to .claw/
388
+ try {
389
+ appendLog(dispatch.worktreePath, {
390
+ ts: new Date().toISOString(), phase: "audit", attempt: dispatch.attempt,
391
+ agent: "auditor", prompt: "(audit task)",
392
+ outputPreview: auditOutput.slice(0, 500), success: event.success,
393
+ });
394
+ } catch {}
395
+
374
396
  // Parse verdict
375
397
  const verdict = parseVerdict(auditOutput);
376
398
  if (!verdict) {
@@ -406,9 +428,13 @@ async function handleAuditPass(
406
428
  dispatch: ActiveDispatch,
407
429
  verdict: AuditVerdict,
408
430
  ): Promise<void> {
409
- const { api, linearApi, notify, configPath } = hookCtx;
431
+ const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
410
432
  const TAG = `[${dispatch.issueIdentifier}]`;
411
433
 
434
+ // Save audit verdict to .claw/
435
+ try { saveAuditVerdict(dispatch.worktreePath, dispatch.attempt, verdict); } catch {}
436
+ try { updateManifest(dispatch.worktreePath, { status: "done", attempts: dispatch.attempt + 1 }); } catch {}
437
+
412
438
  // CAS transition: auditing → done
413
439
  try {
414
440
  await transitionDispatch(dispatch.issueIdentifier, "auditing", "done", undefined, configPath);
@@ -428,11 +454,26 @@ async function handleAuditPass(
428
454
  project: dispatch.project,
429
455
  }, configPath);
430
456
 
431
- // Post approval comment
457
+ // Build summary from .claw/ artifacts and write to memory
458
+ let summary: string | null = null;
459
+ try {
460
+ summary = buildSummaryFromArtifacts(dispatch.worktreePath);
461
+ if (summary) {
462
+ writeSummary(dispatch.worktreePath, summary);
463
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
464
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
465
+ api.logger.info(`${TAG} .claw/ summary and memory written`);
466
+ }
467
+ } catch (err) {
468
+ api.logger.warn(`${TAG} failed to write summary/memory: ${err}`);
469
+ }
470
+
471
+ // Post approval comment (with summary excerpt if available)
432
472
  const criteriaList = verdict.criteria.map((c) => `- ${c}`).join("\n");
473
+ const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
433
474
  await linearApi.createComment(
434
475
  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.*`,
476
+ `## Audit Passed\n\n**Criteria verified:**\n${criteriaList}\n\n**Tests:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Attempt ${dispatch.attempt + 1} — audit passed. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
436
477
  ).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
437
478
 
438
479
  api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
@@ -458,8 +499,13 @@ async function handleAuditFail(
458
499
  const maxAttempts = (pluginConfig?.maxReworkAttempts as number) ?? 2;
459
500
  const nextAttempt = dispatch.attempt + 1;
460
501
 
502
+ // Save audit verdict to .claw/ (both escalation and rework paths)
503
+ try { saveAuditVerdict(dispatch.worktreePath, dispatch.attempt, verdict); } catch {}
504
+
461
505
  if (nextAttempt > maxAttempts) {
462
506
  // Escalate — too many failures
507
+ try { updateManifest(dispatch.worktreePath, { status: "stuck", attempts: nextAttempt }); } catch {}
508
+
463
509
  try {
464
510
  await transitionDispatch(
465
511
  dispatch.issueIdentifier,
@@ -476,10 +522,20 @@ async function handleAuditFail(
476
522
  throw err;
477
523
  }
478
524
 
525
+ // Write summary + memory for stuck dispatches too
526
+ try {
527
+ const summary = buildSummaryFromArtifacts(dispatch.worktreePath);
528
+ if (summary) {
529
+ writeSummary(dispatch.worktreePath, summary);
530
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
531
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
532
+ }
533
+ } catch {}
534
+
479
535
  const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
480
536
  await linearApi.createComment(
481
537
  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.*`,
538
+ `## 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. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
483
539
  ).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
484
540
 
485
541
  api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
@@ -608,6 +664,7 @@ export async function spawnWorker(
608
664
 
609
665
  api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
610
666
 
667
+ const workerStartTime = Date.now();
611
668
  const result = await runAgent({
612
669
  api,
613
670
  agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
@@ -616,6 +673,19 @@ export async function spawnWorker(
616
673
  timeoutMs: (pluginConfig?.codexTimeoutMs as number) ?? 10 * 60_000,
617
674
  });
618
675
 
676
+ // Save worker output to .claw/
677
+ const workerElapsed = Date.now() - workerStartTime;
678
+ try { saveWorkerOutput(dispatch.worktreePath, dispatch.attempt, result.output); } catch {}
679
+ try {
680
+ appendLog(dispatch.worktreePath, {
681
+ ts: new Date().toISOString(), phase: "worker", attempt: dispatch.attempt,
682
+ agent: (pluginConfig?.defaultAgentId as string) ?? "default",
683
+ prompt: workerPrompt.task.slice(0, 200),
684
+ outputPreview: result.output.slice(0, 500),
685
+ success: result.success, durationMs: workerElapsed,
686
+ });
687
+ } catch {}
688
+
619
689
  // runAgent returns inline — trigger audit directly.
620
690
  // Re-read dispatch state since it may have changed during worker run.
621
691
  const freshState = await readDispatchState(configPath);
package/src/webhook.ts CHANGED
@@ -9,6 +9,7 @@ import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchS
9
9
  import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./notify.js";
10
10
  import { assessTier } from "./tier-assess.js";
11
11
  import { createWorktree, prepareWorkspace } from "./codex-worktree.js";
12
+ import { ensureClawDir, writeManifest } from "./artifacts.js";
12
13
 
13
14
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
14
15
  interface AgentProfile {
@@ -187,12 +188,18 @@ export async function handleLinearWebhook(
187
188
  .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
188
189
  .join("\n");
189
190
 
191
+ const notifIssueRef = enrichedIssue?.identifier ?? issue.id;
190
192
  const message = [
191
- `IMPORTANT: You are responding to a Linear issue notification. Your ENTIRE text output will be automatically posted as a comment on the issue. Do NOT attempt to post to Linear yourself — no tools, no CLI, no API calls. Just write your response as plain text/markdown.`,
193
+ `You are an orchestrator responding to a Linear issue notification. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
192
194
  ``,
193
- `You were mentioned/assigned in a Linear issue. Respond naturally and helpfully.`,
195
+ `**Tool access:**`,
196
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${notifIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
197
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
198
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
194
199
  ``,
195
- `## Issue: ${enrichedIssue?.identifier ?? issue.id}${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
200
+ `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status the audit system handles lifecycle.`,
201
+ ``,
202
+ `## Issue: ${notifIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
196
203
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
197
204
  ``,
198
205
  `**Description:**`,
@@ -200,7 +207,7 @@ export async function handleLinearWebhook(
200
207
  commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
201
208
  comment?.body ? `\n**Triggering comment:**\n> ${comment.body}` : "",
202
209
  ``,
203
- `Respond concisely. If there's a task, explain what you'll do and do it.`,
210
+ `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
204
211
  ].filter(Boolean).join("\n");
205
212
 
206
213
  // Dispatch agent with session lifecycle (non-blocking)
@@ -363,11 +370,19 @@ export async function handleLinearWebhook(
363
370
  .map((c: any) => `**${c.user?.name ?? c.actorName ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
364
371
  .join("\n\n");
365
372
 
373
+ const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
366
374
  const message = [
367
- `You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
368
- `You have access to tools including \`code_run\` for coding tasks, \`spawn_agent\`/\`ask_agent\` for delegation, and standard tools (exec, read, edit, write, web_search, etc.). To manage Linear issues (update status, close, assign, comment, etc.) use the \`linearis\` CLI via exec — e.g. \`linearis issues update API-123 --status done\` to close an issue, \`linearis issues update API-123 --status "In Progress"\` to change status, \`linearis issues list\` to list issues. Run \`linearis usage\` for full command reference.`,
375
+ `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
376
+ ``,
377
+ `**Tool access:**`,
378
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
379
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
380
+ `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
381
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
382
+ ``,
383
+ `**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`,
369
384
  ``,
370
- `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
385
+ `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
371
386
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
372
387
  ``,
373
388
  `**Description:**`,
@@ -375,7 +390,7 @@ export async function handleLinearWebhook(
375
390
  commentContext ? `\n**Conversation:**\n${commentContext}` : "",
376
391
  userMessage ? `\n**Latest message:**\n> ${userMessage}` : "",
377
392
  ``,
378
- `Respond to the user's request. If they ask you to write code or make changes, use the \`code_run\` tool. Be concise and action-oriented.`,
393
+ `Respond to the user's request. For work requests, dispatch via \`code_run\` and summarize the result. Be concise and action-oriented.`,
379
394
  ].filter(Boolean).join("\n");
380
395
 
381
396
  // Run agent directly (non-blocking)
@@ -543,11 +558,19 @@ export async function handleLinearWebhook(
543
558
  .map((c: any) => `**${c.user?.name ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
544
559
  .join("\n\n");
545
560
 
561
+ const followUpIssueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
546
562
  const message = [
547
- `You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
548
- `You have access to tools including \`code_run\` for coding tasks, \`spawn_agent\`/\`ask_agent\` for delegation, and standard tools (exec, read, edit, write, web_search, etc.). To manage Linear issues (update status, close, assign, comment, etc.) use the \`linearis\` CLI via exec — e.g. \`linearis issues update API-123 --status done\` to close an issue, \`linearis issues update API-123 --status "In Progress"\` to change status, \`linearis issues list\` to list issues. Run \`linearis usage\` for full command reference.`,
563
+ `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
564
+ ``,
565
+ `**Tool access:**`,
566
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${followUpIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
567
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
568
+ `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
569
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
570
+ ``,
571
+ `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
549
572
  ``,
550
- `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
573
+ `## Issue: ${followUpIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
551
574
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
552
575
  ``,
553
576
  `**Description:**`,
@@ -555,7 +578,7 @@ export async function handleLinearWebhook(
555
578
  commentContext ? `\n**Recent conversation:**\n${commentContext}` : "",
556
579
  `\n**User's follow-up message:**\n> ${userMessage}`,
557
580
  ``,
558
- `Respond to the user's follow-up. Be concise and action-oriented.`,
581
+ `Respond to the user's follow-up. For work requests, dispatch via \`code_run\`. Be concise and action-oriented.`,
559
582
  ].filter(Boolean).join("\n");
560
583
 
561
584
  setActiveSession({
@@ -683,6 +706,14 @@ export async function handleLinearWebhook(
683
706
 
684
707
  api.logger.info(`Comment mention: @${mentionedAgent} on ${issue.identifier ?? issue.id} by ${commentor}`);
685
708
 
709
+ // Guard: skip if an agent run is already active for this issue
710
+ // (prevents dual-dispatch when both Comment.create and AgentSessionEvent fire)
711
+ if (activeRuns.has(issue.id)) {
712
+ api.logger.info(`Comment mention: agent already running for ${issue.identifier ?? issue.id} — skipping`);
713
+ return true;
714
+ }
715
+ activeRuns.add(issue.id);
716
+
686
717
  // React with eyes to acknowledge the comment
687
718
  if (comment?.id) {
688
719
  linearApi.createReaction(comment.id, "eyes").catch(() => {});
@@ -712,9 +743,14 @@ export async function handleLinearWebhook(
712
743
  const assignee = enrichedIssue.assignee?.name ?? "Unassigned";
713
744
 
714
745
  const taskMessage = [
715
- `IMPORTANT: You are responding to a Linear issue comment. Your ENTIRE text output will be automatically posted as a comment on the issue. Do NOT attempt to post to Linear yourself — no tools, no CLI, no API calls. Just write your response as plain text/markdown.`,
746
+ `You are an orchestrator responding to a Linear issue comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
747
+ ``,
748
+ `**Tool access:**`,
749
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${enrichedIssue.identifier ?? "API-XXX"}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
750
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
751
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
716
752
  ``,
717
- `You were mentioned by name. Respond naturally and helpfully as a team member. Be concise, markdown-friendly. Do NOT use JSON or structured output.`,
753
+ `**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`,
718
754
  ``,
719
755
  `**Issue:** ${enrichedIssue.identifier ?? enrichedIssue.id} — ${enrichedIssue.title ?? "(untitled)"}`,
720
756
  `**Status:** ${state} | **Priority:** ${priority} | **Assignee:** ${assignee} | **Labels:** ${labels}`,
@@ -725,7 +761,7 @@ export async function handleLinearWebhook(
725
761
  `**${commentor} wrote:**`,
726
762
  `> ${commentBody}`,
727
763
  ``,
728
- `Respond to their message. Be concise and direct. If they're asking you to do work, explain what you'll do and do it.`,
764
+ `Respond to their message. Be concise and direct. For work requests, dispatch via \`code_run\` and summarize the result.`,
729
765
  ].filter(Boolean).join("\n");
730
766
 
731
767
  // Dispatch to agent with full session lifecycle (non-blocking)
@@ -814,6 +850,7 @@ export async function handleLinearWebhook(
814
850
  }
815
851
  } finally {
816
852
  clearActiveSession(issue.id);
853
+ activeRuns.delete(issue.id);
817
854
  }
818
855
  })();
819
856
 
@@ -1214,11 +1251,32 @@ async function handleDispatch(
1214
1251
  api.logger.warn(`@dispatch: could not create agent session: ${err}`);
1215
1252
  }
1216
1253
 
1254
+ // 6b. Initialize .claw/ artifact directory
1255
+ try {
1256
+ ensureClawDir(worktree.path);
1257
+ writeManifest(worktree.path, {
1258
+ issueIdentifier: identifier,
1259
+ issueTitle: enrichedIssue.title ?? "(untitled)",
1260
+ issueId: issue.id,
1261
+ tier: assessment.tier,
1262
+ model: assessment.model,
1263
+ dispatchedAt: new Date().toISOString(),
1264
+ worktreePath: worktree.path,
1265
+ branch: worktree.branch,
1266
+ attempts: 0,
1267
+ status: "dispatched",
1268
+ plugin: "openclaw-linear",
1269
+ });
1270
+ } catch (err) {
1271
+ api.logger.warn(`@dispatch: .claw/ init failed: ${err}`);
1272
+ }
1273
+
1217
1274
  // 7. Register dispatch in persistent state
1218
1275
  const now = new Date().toISOString();
1219
1276
  await registerDispatch(identifier, {
1220
1277
  issueId: issue.id,
1221
1278
  issueIdentifier: identifier,
1279
+ issueTitle: enrichedIssue.title ?? "(untitled)",
1222
1280
  worktreePath: worktree.path,
1223
1281
  branch: worktree.branch,
1224
1282
  tier: assessment.tier,