@calltelemetry/openclaw-linear 0.5.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.5.1",
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",
@@ -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
+ }
@@ -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 {
@@ -1250,11 +1251,32 @@ async function handleDispatch(
1250
1251
  api.logger.warn(`@dispatch: could not create agent session: ${err}`);
1251
1252
  }
1252
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
+
1253
1274
  // 7. Register dispatch in persistent state
1254
1275
  const now = new Date().toISOString();
1255
1276
  await registerDispatch(identifier, {
1256
1277
  issueId: issue.id,
1257
1278
  issueIdentifier: identifier,
1279
+ issueTitle: enrichedIssue.title ?? "(untitled)",
1258
1280
  worktreePath: worktree.path,
1259
1281
  branch: worktree.branch,
1260
1282
  tier: assessment.tier,