@calltelemetry/openclaw-linear 0.5.1 → 0.6.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.
Files changed (35) hide show
  1. package/README.md +359 -195
  2. package/index.ts +10 -10
  3. package/openclaw.plugin.json +4 -1
  4. package/package.json +9 -2
  5. package/src/agent/agent.test.ts +127 -0
  6. package/src/{agent.ts → agent/agent.ts} +84 -7
  7. package/src/agent/watchdog.test.ts +266 -0
  8. package/src/agent/watchdog.ts +176 -0
  9. package/src/{cli.ts → infra/cli.ts} +5 -5
  10. package/src/{codex-worktree.ts → infra/codex-worktree.ts} +4 -0
  11. package/src/infra/notify.test.ts +169 -0
  12. package/src/{notify.ts → infra/notify.ts} +6 -1
  13. package/src/pipeline/active-session.test.ts +154 -0
  14. package/src/pipeline/artifacts.test.ts +383 -0
  15. package/src/pipeline/artifacts.ts +273 -0
  16. package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
  17. package/src/pipeline/dispatch-state.test.ts +382 -0
  18. package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +1 -0
  19. package/src/pipeline/pipeline.test.ts +226 -0
  20. package/src/{pipeline.ts → pipeline/pipeline.ts} +134 -10
  21. package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
  22. package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
  23. package/src/{webhook.ts → pipeline/webhook.ts} +30 -8
  24. package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
  25. package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
  26. package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
  27. package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
  28. package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
  29. package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
  30. package/src/client.ts +0 -94
  31. /package/src/{auth.ts → api/auth.ts} +0 -0
  32. /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
  33. /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
  34. /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
  35. /package/src/{tools.ts → tools/tools.ts} +0 -0
@@ -0,0 +1,383 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ // Mock pipeline.js to break the import chain: artifacts → pipeline → agent → extensionAPI
7
+ vi.mock("./pipeline.js", () => ({}));
8
+
9
+ import {
10
+ ensureClawDir,
11
+ ensureGitignore,
12
+ writeManifest,
13
+ readManifest,
14
+ updateManifest,
15
+ saveWorkerOutput,
16
+ savePlan,
17
+ saveAuditVerdict,
18
+ appendLog,
19
+ writeSummary,
20
+ buildSummaryFromArtifacts,
21
+ writeDispatchMemory,
22
+ resolveOrchestratorWorkspace,
23
+ type ClawManifest,
24
+ type LogEntry,
25
+ } from "./artifacts.js";
26
+
27
+ function makeTmpDir(): string {
28
+ return mkdtempSync(join(tmpdir(), "claw-test-"));
29
+ }
30
+
31
+ function makeManifest(overrides?: Partial<ClawManifest>): ClawManifest {
32
+ return {
33
+ issueIdentifier: "API-100",
34
+ issueTitle: "Fix login bug",
35
+ issueId: "id-123",
36
+ tier: "junior",
37
+ model: "test-model",
38
+ dispatchedAt: "2026-01-01T00:00:00Z",
39
+ worktreePath: "/tmp/test",
40
+ branch: "codex/API-100",
41
+ attempts: 0,
42
+ status: "dispatched",
43
+ plugin: "linear",
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // ensureClawDir
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe("ensureClawDir", () => {
53
+ it("creates .claw/ directory and returns path", () => {
54
+ const tmp = makeTmpDir();
55
+ const result = ensureClawDir(tmp);
56
+ expect(result).toBe(join(tmp, ".claw"));
57
+ expect(existsSync(result)).toBe(true);
58
+ });
59
+
60
+ it("is idempotent", () => {
61
+ const tmp = makeTmpDir();
62
+ const first = ensureClawDir(tmp);
63
+ const second = ensureClawDir(tmp);
64
+ expect(first).toBe(second);
65
+ });
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // ensureGitignore
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe("ensureGitignore", () => {
73
+ it("creates .gitignore with .claw/ entry", () => {
74
+ const tmp = makeTmpDir();
75
+ ensureGitignore(tmp);
76
+ const content = readFileSync(join(tmp, ".gitignore"), "utf-8");
77
+ expect(content).toContain(".claw/");
78
+ });
79
+
80
+ it("appends to existing .gitignore", () => {
81
+ const tmp = makeTmpDir();
82
+ writeFileSync(join(tmp, ".gitignore"), "node_modules/\n", "utf-8");
83
+ ensureGitignore(tmp);
84
+ const content = readFileSync(join(tmp, ".gitignore"), "utf-8");
85
+ expect(content).toContain("node_modules/");
86
+ expect(content).toContain(".claw/");
87
+ });
88
+
89
+ it("does not duplicate entry", () => {
90
+ const tmp = makeTmpDir();
91
+ ensureGitignore(tmp);
92
+ ensureGitignore(tmp);
93
+ const content = readFileSync(join(tmp, ".gitignore"), "utf-8");
94
+ const matches = content.match(/\.claw\//g);
95
+ expect(matches).toHaveLength(1);
96
+ });
97
+
98
+ it("handles file without trailing newline", () => {
99
+ const tmp = makeTmpDir();
100
+ writeFileSync(join(tmp, ".gitignore"), "node_modules/", "utf-8"); // no trailing \n
101
+ ensureGitignore(tmp);
102
+ const content = readFileSync(join(tmp, ".gitignore"), "utf-8");
103
+ expect(content).toBe("node_modules/\n.claw/\n");
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Manifest CRUD
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("manifest", () => {
112
+ it("write + read round-trip", () => {
113
+ const tmp = makeTmpDir();
114
+ const manifest = makeManifest({ worktreePath: tmp });
115
+ writeManifest(tmp, manifest);
116
+ const read = readManifest(tmp);
117
+ expect(read).toEqual(manifest);
118
+ });
119
+
120
+ it("readManifest returns null when missing", () => {
121
+ const tmp = makeTmpDir();
122
+ expect(readManifest(tmp)).toBeNull();
123
+ });
124
+
125
+ it("updateManifest merges partial updates", () => {
126
+ const tmp = makeTmpDir();
127
+ writeManifest(tmp, makeManifest({ worktreePath: tmp }));
128
+ updateManifest(tmp, { status: "working", attempts: 1 });
129
+ const read = readManifest(tmp);
130
+ expect(read!.status).toBe("working");
131
+ expect(read!.attempts).toBe(1);
132
+ expect(read!.issueIdentifier).toBe("API-100"); // preserved
133
+ });
134
+
135
+ it("updateManifest no-op when no manifest", () => {
136
+ const tmp = makeTmpDir();
137
+ // Should not throw
138
+ updateManifest(tmp, { status: "done" });
139
+ expect(readManifest(tmp)).toBeNull();
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Phase artifacts
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe("saveWorkerOutput", () => {
148
+ it("writes worker-{N}.md", () => {
149
+ const tmp = makeTmpDir();
150
+ saveWorkerOutput(tmp, 0, "hello world");
151
+ const content = readFileSync(join(tmp, ".claw", "worker-0.md"), "utf-8");
152
+ expect(content).toBe("hello world");
153
+ });
154
+
155
+ it("truncates at 8192 bytes", () => {
156
+ const tmp = makeTmpDir();
157
+ const longOutput = "x".repeat(10000);
158
+ saveWorkerOutput(tmp, 1, longOutput);
159
+ const content = readFileSync(join(tmp, ".claw", "worker-1.md"), "utf-8");
160
+ expect(content.length).toBeLessThan(10000);
161
+ expect(content).toContain("--- truncated ---");
162
+ });
163
+
164
+ it("does not truncate under limit", () => {
165
+ const tmp = makeTmpDir();
166
+ const shortOutput = "y".repeat(100);
167
+ saveWorkerOutput(tmp, 2, shortOutput);
168
+ const content = readFileSync(join(tmp, ".claw", "worker-2.md"), "utf-8");
169
+ expect(content).toBe(shortOutput);
170
+ });
171
+ });
172
+
173
+ describe("savePlan", () => {
174
+ it("writes plan.md", () => {
175
+ const tmp = makeTmpDir();
176
+ savePlan(tmp, "# My Plan\n\nStep 1...");
177
+ const content = readFileSync(join(tmp, ".claw", "plan.md"), "utf-8");
178
+ expect(content).toBe("# My Plan\n\nStep 1...");
179
+ });
180
+ });
181
+
182
+ describe("saveAuditVerdict", () => {
183
+ it("writes audit-{N}.json with correct JSON", () => {
184
+ const tmp = makeTmpDir();
185
+ const verdict = { pass: true, criteria: ["tests pass"], gaps: [], testResults: "all green" };
186
+ saveAuditVerdict(tmp, 0, verdict);
187
+ const raw = readFileSync(join(tmp, ".claw", "audit-0.json"), "utf-8");
188
+ expect(JSON.parse(raw)).toEqual(verdict);
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Interaction log
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("appendLog", () => {
197
+ function makeEntry(overrides?: Partial<LogEntry>): LogEntry {
198
+ return {
199
+ ts: "2026-01-01T00:00:00Z",
200
+ phase: "worker",
201
+ attempt: 0,
202
+ agent: "zoe",
203
+ prompt: "do the thing",
204
+ outputPreview: "done",
205
+ success: true,
206
+ ...overrides,
207
+ };
208
+ }
209
+
210
+ it("creates log.jsonl and writes entry", () => {
211
+ const tmp = makeTmpDir();
212
+ appendLog(tmp, makeEntry());
213
+ const content = readFileSync(join(tmp, ".claw", "log.jsonl"), "utf-8");
214
+ const parsed = JSON.parse(content.trim());
215
+ expect(parsed.phase).toBe("worker");
216
+ expect(parsed.success).toBe(true);
217
+ });
218
+
219
+ it("appends multiple entries as JSONL", () => {
220
+ const tmp = makeTmpDir();
221
+ appendLog(tmp, makeEntry({ attempt: 0 }));
222
+ appendLog(tmp, makeEntry({ attempt: 1, phase: "audit" }));
223
+ const lines = readFileSync(join(tmp, ".claw", "log.jsonl"), "utf-8")
224
+ .trim()
225
+ .split("\n");
226
+ expect(lines).toHaveLength(2);
227
+ expect(JSON.parse(lines[1]).phase).toBe("audit");
228
+ });
229
+
230
+ it("truncates prompt to 200 chars", () => {
231
+ const tmp = makeTmpDir();
232
+ appendLog(tmp, makeEntry({ prompt: "x".repeat(500) }));
233
+ const content = readFileSync(join(tmp, ".claw", "log.jsonl"), "utf-8");
234
+ const parsed = JSON.parse(content.trim());
235
+ expect(parsed.prompt.length).toBe(200);
236
+ });
237
+
238
+ it("truncates outputPreview to 500 chars", () => {
239
+ const tmp = makeTmpDir();
240
+ appendLog(tmp, makeEntry({ outputPreview: "y".repeat(1000) }));
241
+ const content = readFileSync(join(tmp, ".claw", "log.jsonl"), "utf-8");
242
+ const parsed = JSON.parse(content.trim());
243
+ expect(parsed.outputPreview.length).toBe(500);
244
+ });
245
+
246
+ it("preserves watchdog detail when phase=watchdog", () => {
247
+ const tmp = makeTmpDir();
248
+ appendLog(tmp, makeEntry({
249
+ phase: "watchdog",
250
+ watchdog: { reason: "inactivity", silenceSec: 120, thresholdSec: 120, retried: true },
251
+ }));
252
+ const content = readFileSync(join(tmp, ".claw", "log.jsonl"), "utf-8");
253
+ const parsed = JSON.parse(content.trim());
254
+ expect(parsed.phase).toBe("watchdog");
255
+ expect(parsed.watchdog).toEqual({
256
+ reason: "inactivity",
257
+ silenceSec: 120,
258
+ thresholdSec: 120,
259
+ retried: true,
260
+ });
261
+ });
262
+
263
+ it("omits watchdog field when undefined", () => {
264
+ const tmp = makeTmpDir();
265
+ appendLog(tmp, makeEntry());
266
+ const content = readFileSync(join(tmp, ".claw", "log.jsonl"), "utf-8");
267
+ const parsed = JSON.parse(content.trim());
268
+ expect(parsed.watchdog).toBeUndefined();
269
+ });
270
+ });
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Summary
274
+ // ---------------------------------------------------------------------------
275
+
276
+ describe("writeSummary", () => {
277
+ it("writes summary.md", () => {
278
+ const tmp = makeTmpDir();
279
+ writeSummary(tmp, "# Summary\nAll done.");
280
+ const content = readFileSync(join(tmp, ".claw", "summary.md"), "utf-8");
281
+ expect(content).toBe("# Summary\nAll done.");
282
+ });
283
+ });
284
+
285
+ describe("buildSummaryFromArtifacts", () => {
286
+ it("returns null with no manifest", () => {
287
+ const tmp = makeTmpDir();
288
+ expect(buildSummaryFromArtifacts(tmp)).toBeNull();
289
+ });
290
+
291
+ it("builds markdown with header", () => {
292
+ const tmp = makeTmpDir();
293
+ writeManifest(tmp, makeManifest({ worktreePath: tmp }));
294
+ const summary = buildSummaryFromArtifacts(tmp)!;
295
+ expect(summary).toContain("# Dispatch: API-100");
296
+ expect(summary).toContain("Fix login bug");
297
+ });
298
+
299
+ it("includes plan section when plan exists", () => {
300
+ const tmp = makeTmpDir();
301
+ writeManifest(tmp, makeManifest({ worktreePath: tmp }));
302
+ savePlan(tmp, "Step 1: do stuff");
303
+ const summary = buildSummaryFromArtifacts(tmp)!;
304
+ expect(summary).toContain("## Plan");
305
+ expect(summary).toContain("Step 1: do stuff");
306
+ });
307
+
308
+ it("includes worker+audit per attempt", () => {
309
+ const tmp = makeTmpDir();
310
+ writeManifest(tmp, makeManifest({ worktreePath: tmp, attempts: 2 }));
311
+ saveWorkerOutput(tmp, 0, "attempt 0 output");
312
+ saveAuditVerdict(tmp, 0, { pass: false, criteria: ["c1"], gaps: ["g1"], testResults: "fail" });
313
+ saveWorkerOutput(tmp, 1, "attempt 1 output");
314
+ saveAuditVerdict(tmp, 1, { pass: true, criteria: ["c2"], gaps: [], testResults: "pass" });
315
+
316
+ const summary = buildSummaryFromArtifacts(tmp)!;
317
+ expect(summary).toContain("## Attempt 0");
318
+ expect(summary).toContain("attempt 0 output");
319
+ expect(summary).toContain("FAIL");
320
+ expect(summary).toContain("g1");
321
+ expect(summary).toContain("## Attempt 1");
322
+ expect(summary).toContain("attempt 1 output");
323
+ expect(summary).toContain("PASS");
324
+ });
325
+
326
+ it("handles missing artifact files gracefully", () => {
327
+ const tmp = makeTmpDir();
328
+ writeManifest(tmp, makeManifest({ worktreePath: tmp, attempts: 1 }));
329
+ // No worker or audit files — should not throw
330
+ const summary = buildSummaryFromArtifacts(tmp)!;
331
+ expect(summary).toContain("## Attempt 0");
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Memory
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe("writeDispatchMemory", () => {
340
+ it("creates memory/ dir and writes file", () => {
341
+ const tmp = makeTmpDir();
342
+ writeDispatchMemory("API-100", "summary content", tmp);
343
+ const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
344
+ expect(content).toBe("summary content");
345
+ });
346
+
347
+ it("overwrites on second call", () => {
348
+ const tmp = makeTmpDir();
349
+ writeDispatchMemory("API-100", "first", tmp);
350
+ writeDispatchMemory("API-100", "second", tmp);
351
+ const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
352
+ expect(content).toBe("second");
353
+ });
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Orchestrator workspace
358
+ // ---------------------------------------------------------------------------
359
+
360
+ describe("resolveOrchestratorWorkspace", () => {
361
+ it("falls back to default on error", () => {
362
+ const api = { runtime: { config: { loadConfig: () => { throw new Error("no config"); } } } };
363
+ const result = resolveOrchestratorWorkspace(api);
364
+ expect(result).toContain(".openclaw");
365
+ expect(result).toContain("workspace");
366
+ });
367
+
368
+ it("returns workspace from config", () => {
369
+ const api = {
370
+ runtime: {
371
+ config: {
372
+ loadConfig: () => ({
373
+ agents: {
374
+ list: [{ id: "default", workspace: "/custom/ws" }],
375
+ },
376
+ }),
377
+ },
378
+ },
379
+ };
380
+ const result = resolveOrchestratorWorkspace(api);
381
+ expect(result).toBe("/custom/ws");
382
+ });
383
+ });
@@ -0,0 +1,273 @@
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 WatchdogLogDetail {
49
+ reason: "inactivity";
50
+ silenceSec: number;
51
+ thresholdSec: number;
52
+ retried: boolean;
53
+ }
54
+
55
+ export interface LogEntry {
56
+ ts: string;
57
+ phase: "worker" | "audit" | "verdict" | "dispatch" | "watchdog";
58
+ attempt: number;
59
+ agent: string;
60
+ prompt: string;
61
+ outputPreview: string;
62
+ success: boolean;
63
+ durationMs?: number;
64
+ watchdog?: WatchdogLogDetail;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Directory setup
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function clawDir(worktreePath: string): string {
72
+ return join(worktreePath, ".claw");
73
+ }
74
+
75
+ /** Creates .claw/ directory. Returns the path. */
76
+ export function ensureClawDir(worktreePath: string): string {
77
+ const dir = clawDir(worktreePath);
78
+ if (!existsSync(dir)) {
79
+ mkdirSync(dir, { recursive: true });
80
+ }
81
+ return dir;
82
+ }
83
+
84
+ /**
85
+ * Ensure .claw/ is in .gitignore at the worktree root.
86
+ * Appends if not already present. Idempotent.
87
+ */
88
+ export function ensureGitignore(worktreePath: string): void {
89
+ const gitignorePath = join(worktreePath, ".gitignore");
90
+ try {
91
+ const content = existsSync(gitignorePath)
92
+ ? readFileSync(gitignorePath, "utf-8")
93
+ : "";
94
+ if (!content.split("\n").some((line) => line.trim() === ".claw" || line.trim() === ".claw/")) {
95
+ const nl = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
96
+ appendFileSync(gitignorePath, `${nl}.claw/\n`, "utf-8");
97
+ }
98
+ } catch {
99
+ // Best effort — don't block pipeline
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Manifest
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export function writeManifest(worktreePath: string, manifest: ClawManifest): void {
108
+ const dir = ensureClawDir(worktreePath);
109
+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
110
+ }
111
+
112
+ export function readManifest(worktreePath: string): ClawManifest | null {
113
+ try {
114
+ const raw = readFileSync(join(clawDir(worktreePath), "manifest.json"), "utf-8");
115
+ return JSON.parse(raw) as ClawManifest;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ export function updateManifest(worktreePath: string, updates: Partial<ClawManifest>): void {
122
+ const current = readManifest(worktreePath);
123
+ if (!current) return;
124
+ writeManifest(worktreePath, { ...current, ...updates });
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Phase artifacts
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /** Save worker output for a given attempt. Truncated to MAX_ARTIFACT_SIZE. */
132
+ export function saveWorkerOutput(worktreePath: string, attempt: number, output: string): void {
133
+ const dir = ensureClawDir(worktreePath);
134
+ const truncated = output.length > MAX_ARTIFACT_SIZE
135
+ ? output.slice(0, MAX_ARTIFACT_SIZE) + "\n\n--- truncated ---"
136
+ : output;
137
+ writeFileSync(join(dir, `worker-${attempt}.md`), truncated, "utf-8");
138
+ }
139
+
140
+ /** Save a plan (extracted from worker output or provided directly). */
141
+ export function savePlan(worktreePath: string, plan: string): void {
142
+ const dir = ensureClawDir(worktreePath);
143
+ writeFileSync(join(dir, "plan.md"), plan, "utf-8");
144
+ }
145
+
146
+ /** Save audit verdict for a given attempt. */
147
+ export function saveAuditVerdict(worktreePath: string, attempt: number, verdict: AuditVerdict): void {
148
+ const dir = ensureClawDir(worktreePath);
149
+ writeFileSync(join(dir, `audit-${attempt}.json`), JSON.stringify(verdict, null, 2) + "\n", "utf-8");
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Interaction log
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Append a structured log entry to .claw/log.jsonl. */
157
+ export function appendLog(worktreePath: string, entry: LogEntry): void {
158
+ const dir = ensureClawDir(worktreePath);
159
+ const truncated: LogEntry = {
160
+ ...entry,
161
+ prompt: entry.prompt.slice(0, MAX_PROMPT_PREVIEW),
162
+ outputPreview: entry.outputPreview.slice(0, MAX_PREVIEW_SIZE),
163
+ };
164
+ appendFileSync(join(dir, "log.jsonl"), JSON.stringify(truncated) + "\n", "utf-8");
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Summary
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** Write the final curated summary. */
172
+ export function writeSummary(worktreePath: string, summary: string): void {
173
+ const dir = ensureClawDir(worktreePath);
174
+ writeFileSync(join(dir, "summary.md"), summary, "utf-8");
175
+ }
176
+
177
+ /**
178
+ * Build a markdown summary from all .claw/ artifacts.
179
+ * Used at issue completion to generate a memory-friendly summary.
180
+ */
181
+ export function buildSummaryFromArtifacts(worktreePath: string): string | null {
182
+ const manifest = readManifest(worktreePath);
183
+ if (!manifest) return null;
184
+
185
+ const parts: string[] = [];
186
+ parts.push(`# Dispatch: ${manifest.issueIdentifier} — ${manifest.issueTitle}`);
187
+ parts.push(`**Tier:** ${manifest.tier} | **Status:** ${manifest.status} | **Attempts:** ${manifest.attempts}`);
188
+ parts.push("");
189
+
190
+ // Include plan if exists
191
+ try {
192
+ const plan = readFileSync(join(clawDir(worktreePath), "plan.md"), "utf-8");
193
+ parts.push("## Plan");
194
+ parts.push(plan.slice(0, 2000));
195
+ parts.push("");
196
+ } catch { /* no plan */ }
197
+
198
+ // Include each attempt's worker + audit
199
+ for (let i = 0; i < manifest.attempts; i++) {
200
+ parts.push(`## Attempt ${i}`);
201
+
202
+ // Worker output preview
203
+ try {
204
+ const worker = readFileSync(join(clawDir(worktreePath), `worker-${i}.md`), "utf-8");
205
+ parts.push("### Worker Output");
206
+ parts.push(worker.slice(0, 1500));
207
+ parts.push("");
208
+ } catch { /* no worker output */ }
209
+
210
+ // Audit verdict
211
+ try {
212
+ const raw = readFileSync(join(clawDir(worktreePath), `audit-${i}.json`), "utf-8");
213
+ const verdict = JSON.parse(raw) as AuditVerdict;
214
+ parts.push(`### Audit: ${verdict.pass ? "PASS" : "FAIL"}`);
215
+ if (verdict.criteria.length) parts.push(`**Criteria:** ${verdict.criteria.join(", ")}`);
216
+ if (verdict.gaps.length) parts.push(`**Gaps:** ${verdict.gaps.join(", ")}`);
217
+ if (verdict.testResults) parts.push(`**Tests:** ${verdict.testResults}`);
218
+ parts.push("");
219
+ } catch { /* no audit */ }
220
+ }
221
+
222
+ parts.push("---");
223
+ parts.push(`*Artifacts: ${worktreePath}/.claw/*`);
224
+
225
+ return parts.join("\n");
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Memory integration
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * Write dispatch summary to the orchestrator's memory directory.
234
+ * Auto-indexed by OpenClaw's sqlite+embeddings memory system.
235
+ */
236
+ export function writeDispatchMemory(
237
+ issueIdentifier: string,
238
+ summary: string,
239
+ workspaceDir: string,
240
+ ): void {
241
+ const memDir = join(workspaceDir, "memory");
242
+ if (!existsSync(memDir)) {
243
+ mkdirSync(memDir, { recursive: true });
244
+ }
245
+ writeFileSync(
246
+ join(memDir, `dispatch-${issueIdentifier}.md`),
247
+ summary,
248
+ "utf-8",
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Resolve the orchestrator agent's workspace directory from config.
254
+ * Same config-based approach as resolveAgentDirs in agent.ts.
255
+ */
256
+ export function resolveOrchestratorWorkspace(
257
+ api: any,
258
+ pluginConfig?: Record<string, unknown>,
259
+ ): string {
260
+ const home = process.env.HOME ?? "/home/claw";
261
+ const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
262
+
263
+ try {
264
+ const config = api.runtime.config.loadConfig() as Record<string, any>;
265
+ const agentList = config?.agents?.list as Array<Record<string, any>> | undefined;
266
+ const agentEntry = agentList?.find((a: any) => a.id === agentId);
267
+ return agentEntry?.workspace
268
+ ?? config?.agents?.defaults?.workspace
269
+ ?? join(home, ".openclaw", "workspace");
270
+ } catch {
271
+ return join(home, ".openclaw", "workspace");
272
+ }
273
+ }
@@ -22,7 +22,7 @@ import {
22
22
  removeActiveDispatch,
23
23
  pruneCompleted,
24
24
  } from "./dispatch-state.js";
25
- import { getWorktreeStatus } from "./codex-worktree.js";
25
+ import { getWorktreeStatus } from "../infra/codex-worktree.js";
26
26
 
27
27
  const INTERVAL_MS = 5 * 60_000; // 5 minutes
28
28
  const STALE_THRESHOLD_MS = 2 * 60 * 60_000; // 2 hours