@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.
- package/README.md +359 -195
- package/index.ts +10 -10
- package/openclaw.plugin.json +4 -1
- package/package.json +9 -2
- package/src/agent/agent.test.ts +127 -0
- package/src/{agent.ts → agent/agent.ts} +84 -7
- package/src/agent/watchdog.test.ts +266 -0
- package/src/agent/watchdog.ts +176 -0
- package/src/{cli.ts → infra/cli.ts} +5 -5
- package/src/{codex-worktree.ts → infra/codex-worktree.ts} +4 -0
- package/src/infra/notify.test.ts +169 -0
- package/src/{notify.ts → infra/notify.ts} +6 -1
- package/src/pipeline/active-session.test.ts +154 -0
- package/src/pipeline/artifacts.test.ts +383 -0
- package/src/pipeline/artifacts.ts +273 -0
- package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
- package/src/pipeline/dispatch-state.test.ts +382 -0
- package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +1 -0
- package/src/pipeline/pipeline.test.ts +226 -0
- package/src/{pipeline.ts → pipeline/pipeline.ts} +134 -10
- package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
- package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
- package/src/{webhook.ts → pipeline/webhook.ts} +30 -8
- package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
- package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
- package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
- package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
- package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
- package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
- package/src/client.ts +0 -94
- /package/src/{auth.ts → api/auth.ts} +0 -0
- /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
- /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
- /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
- /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 "
|
|
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
|