clawspec 1.0.16 → 1.0.20

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 (40) hide show
  1. package/README.md +16 -0
  2. package/README.zh-CN.md +16 -0
  3. package/package.json +2 -3
  4. package/src/acp/client.ts +17 -1
  5. package/src/orchestrator/helpers.ts +1 -0
  6. package/src/orchestrator/service.ts +143 -13
  7. package/src/watchers/manager.ts +89 -7
  8. package/src/worker/io-helper.ts +6 -5
  9. package/test/acp-client.test.ts +0 -309
  10. package/test/acpx-dependency.test.ts +0 -133
  11. package/test/assistant-journal.test.ts +0 -203
  12. package/test/command-surface.test.ts +0 -23
  13. package/test/config.test.ts +0 -77
  14. package/test/detach-attach.test.ts +0 -98
  15. package/test/file-lock.test.ts +0 -88
  16. package/test/fs-utils.test.ts +0 -22
  17. package/test/helpers/harness.ts +0 -301
  18. package/test/helpers.test.ts +0 -108
  19. package/test/keywords.test.ts +0 -92
  20. package/test/notifier.test.ts +0 -29
  21. package/test/openspec-dependency.test.ts +0 -68
  22. package/test/paths-utils.test.ts +0 -30
  23. package/test/pause-cancel.test.ts +0 -55
  24. package/test/planning-journal.test.ts +0 -155
  25. package/test/plugin-registration.test.ts +0 -35
  26. package/test/project-memory.test.ts +0 -42
  27. package/test/proposal.test.ts +0 -24
  28. package/test/queue-planning.test.ts +0 -322
  29. package/test/queue-work.test.ts +0 -220
  30. package/test/recovery.test.ts +0 -576
  31. package/test/service-archive.test.ts +0 -87
  32. package/test/shell-command.test.ts +0 -48
  33. package/test/state-store.test.ts +0 -74
  34. package/test/tasks-and-checkpoint.test.ts +0 -60
  35. package/test/use-project.test.ts +0 -67
  36. package/test/watcher-planning.test.ts +0 -504
  37. package/test/watcher-work.test.ts +0 -1741
  38. package/test/worker-command.test.ts +0 -66
  39. package/test/worker-io-helper.test.ts +0 -97
  40. package/test/worker-skills.test.ts +0 -12
@@ -1,576 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { mkdtemp, mkdir } from "node:fs/promises";
6
- import { pathExists, readUtf8, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
7
- import { getRepoStatePaths } from "../src/utils/paths.ts";
8
- import { RollbackStore } from "../src/rollback/store.ts";
9
- import { ProjectStateStore } from "../src/state/store.ts";
10
- import { WatcherManager } from "../src/watchers/manager.ts";
11
- import { createLogger, waitFor } from "./helpers/harness.ts";
12
-
13
- function createWorkOpenSpec(changeDir: string, tasksPath: string) {
14
- return {
15
- instructionsApply: async (cwd: string, cn: string) => {
16
- const content = await readUtf8(tasksPath);
17
- const task1Done = content.includes("- [x] 1.1");
18
- const task2Done = content.includes("- [x] 1.2");
19
- const complete = (task1Done ? 1 : 0) + (task2Done ? 1 : 0);
20
- const total = content.includes("1.2") ? 2 : 1;
21
- return {
22
- command: `openspec instructions apply --change ${cn} --json`,
23
- cwd, stdout: "{}", stderr: "", durationMs: 1,
24
- parsed: {
25
- changeName: cn, changeDir, schemaName: "spec-driven",
26
- contextFiles: { tasks: tasksPath },
27
- progress: { total, complete, remaining: total - complete },
28
- tasks: total === 2
29
- ? [
30
- { id: "1.1", description: "First task", done: task1Done },
31
- { id: "1.2", description: "Second task", done: task2Done },
32
- ]
33
- : [{ id: "1.1", description: "Build the demo endpoint", done: task1Done }],
34
- state: complete === total ? "all_done" : "ready",
35
- instruction: "Implement the remaining task.",
36
- },
37
- };
38
- },
39
- } as any;
40
- }
41
-
42
- test("recovery from orphaned state (armed, no execution field)", async () => {
43
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-orphan-"));
44
- const workspacePath = path.join(tempRoot, "workspace");
45
- const repoPath = path.join(workspacePath, "demo-app");
46
- const changeName = "orphan-recovery";
47
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
48
- const tasksPath = path.join(changeDir, "tasks.md");
49
- const outputPath = path.join(repoPath, "src", "demo.txt");
50
- await mkdir(path.join(repoPath, "src"), { recursive: true });
51
- await mkdir(changeDir, { recursive: true });
52
- await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
53
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
54
-
55
- const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
56
- await rollbackStore.initializeBaseline();
57
-
58
- const stateStore = new ProjectStateStore(tempRoot, "archives");
59
- await stateStore.initialize();
60
- const notifierMessages: string[] = [];
61
-
62
- const fakeAcpClient = {
63
- agentId: "codex",
64
- runTurn: async () => {
65
- await writeUtf8(tasksPath, "- [x] 1.1 Build the demo endpoint\n");
66
- await writeUtf8(outputPath, "demo\n");
67
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
68
- version: 1, changeName, mode: "apply", status: "done",
69
- timestamp: new Date().toISOString(), summary: "Completed task 1.1.",
70
- progressMade: true, completedTask: "1.1 Build the demo endpoint",
71
- changedFiles: ["src/demo.txt"], notes: ["Task completed"],
72
- taskCounts: { total: 1, complete: 1, remaining: 0 }, remainingTasks: 0,
73
- });
74
- },
75
- cancelSession: async () => undefined,
76
- closeSession: async () => undefined,
77
- };
78
-
79
- const channelKey = "discord:orphan-recovery:default:main";
80
- await stateStore.createProject(channelKey);
81
- await stateStore.updateProject(channelKey, (current) => ({
82
- ...current,
83
- workspacePath, repoPath, projectName: "demo-app", projectTitle: "Demo App",
84
- changeName, changeDir, status: "armed", phase: "implementing",
85
- workerAgentId: "codex",
86
- currentTask: "1.1 Build the demo endpoint",
87
- taskCounts: { total: 1, complete: 0, remaining: 1 },
88
- planningJournal: { dirty: false, entryCount: 0 },
89
- rollback: {
90
- baselineRoot: rollbackStore.baselineRoot,
91
- manifestPath: rollbackStore.manifestPath,
92
- snapshotReady: true, touchedFileCount: 0,
93
- },
94
- // execution is intentionally NOT set — simulates orphaned state
95
- }));
96
-
97
- // Create stale tmp file to verify cleanup
98
- const clawspecDir = path.join(repoPath, ".openclaw", "clawspec");
99
- await writeUtf8(path.join(clawspecDir, "state.json.12345.999999.tmp"), "stale");
100
-
101
- const manager = new WatcherManager({
102
- stateStore,
103
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
104
- archiveDirName: "archives",
105
- logger: createLogger(),
106
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
107
- acpClient: fakeAcpClient as any,
108
- pollIntervalMs: 25,
109
- });
110
-
111
- await manager.start();
112
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
113
- await manager.stop();
114
-
115
- const project = await stateStore.getActiveProject(channelKey);
116
- assert.equal(project?.status, "done");
117
- assert.equal(project?.phase, "validating");
118
- assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
119
- assert.equal(notifierMessages.some((m) => m.includes("All tasks complete")), true);
120
- assert.equal(await pathExists(path.join(clawspecDir, "state.json.12345.999999.tmp")), false);
121
- });
122
-
123
- test("recovery from mid-crash running state", async () => {
124
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-midcrash-"));
125
- const workspacePath = path.join(tempRoot, "workspace");
126
- const repoPath = path.join(workspacePath, "demo-app");
127
- const changeName = "midcrash-recovery";
128
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
129
- const tasksPath = path.join(changeDir, "tasks.md");
130
- await mkdir(path.join(repoPath, "src"), { recursive: true });
131
- await mkdir(changeDir, { recursive: true });
132
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [ ] 1.2 Second task\n");
133
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
134
-
135
- const stateStore = new ProjectStateStore(tempRoot, "archives");
136
- await stateStore.initialize();
137
- const notifierMessages: string[] = [];
138
-
139
- const fakeAcpClient = {
140
- agentId: "codex",
141
- runTurn: async () => {
142
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [x] 1.2 Second task\n");
143
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
144
- version: 1, changeName, mode: "apply", status: "done",
145
- timestamp: new Date().toISOString(), summary: "Completed task 1.2.",
146
- progressMade: true, completedTask: "1.2 Second task",
147
- changedFiles: [], notes: ["Task completed"],
148
- taskCounts: { total: 2, complete: 2, remaining: 0 }, remainingTasks: 0,
149
- });
150
- },
151
- cancelSession: async () => undefined,
152
- closeSession: async () => undefined,
153
- };
154
-
155
- const channelKey = "discord:midcrash-recovery:default:main";
156
- await stateStore.createProject(channelKey);
157
- await stateStore.updateProject(channelKey, (current) => ({
158
- ...current,
159
- workspacePath, repoPath, projectName: "demo-app", projectTitle: "Demo App",
160
- changeName, changeDir, status: "running", phase: "implementing",
161
- workerAgentId: "codex",
162
- currentTask: "1.2 Second task",
163
- taskCounts: { total: 2, complete: 1, remaining: 1 },
164
- planningJournal: { dirty: false, entryCount: 0 },
165
- execution: {
166
- mode: "apply", action: "work", state: "running",
167
- workerAgentId: "codex", workerSlot: "primary",
168
- armedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
169
- sessionKey: "clawspec:dead-session",
170
- lastHeartbeatAt: new Date(Date.now() - 120_000).toISOString(),
171
- },
172
- }));
173
-
174
- const manager = new WatcherManager({
175
- stateStore,
176
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
177
- archiveDirName: "archives",
178
- logger: createLogger(),
179
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
180
- acpClient: fakeAcpClient as any,
181
- pollIntervalMs: 25,
182
- });
183
-
184
- await manager.start();
185
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
186
- await manager.stop();
187
-
188
- const project = await stateStore.getActiveProject(channelKey);
189
- assert.equal(project?.status, "done");
190
- assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
191
- });
192
-
193
- test("startup recovery adopts a live implementation session instead of spawning a new worker", async () => {
194
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-adopt-live-"));
195
- const workspacePath = path.join(tempRoot, "workspace");
196
- const repoPath = path.join(workspacePath, "demo-app");
197
- const changeName = "adopt-live-recovery";
198
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
199
- const tasksPath = path.join(changeDir, "tasks.md");
200
- const repoStatePaths = getRepoStatePaths(repoPath, "archives");
201
- await mkdir(changeDir, { recursive: true });
202
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [ ] 1.2 Second task\n");
203
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
204
-
205
- const stateStore = new ProjectStateStore(tempRoot, "archives");
206
- await stateStore.initialize();
207
- const notifierMessages: string[] = [];
208
- let runTurnCount = 0;
209
- let cancelCount = 0;
210
-
211
- const existingProgress = `${JSON.stringify({
212
- version: 1,
213
- timestamp: new Date(Date.now() - 60_000).toISOString(),
214
- kind: "task_start",
215
- current: 2,
216
- total: 2,
217
- taskId: "1.2",
218
- message: "Start 1.2: second task already resumed before gateway crash. Next: finish this task.",
219
- })}\n`;
220
- await writeUtf8(repoStatePaths.workerProgressFile, existingProgress);
221
-
222
- const fakeAcpClient = {
223
- agentId: "codex",
224
- runTurn: async () => {
225
- runTurnCount += 1;
226
- },
227
- getSessionStatus: async () => ({
228
- summary: "status=alive pid=4242",
229
- details: {
230
- status: "alive",
231
- pid: 4242,
232
- },
233
- }),
234
- cancelSession: async () => {
235
- cancelCount += 1;
236
- },
237
- closeSession: async () => undefined,
238
- };
239
-
240
- const channelKey = "discord:adopt-live-recovery:default:main";
241
- await stateStore.createProject(channelKey);
242
- await stateStore.updateProject(channelKey, (current) => ({
243
- ...current,
244
- workspacePath,
245
- repoPath,
246
- projectName: "demo-app",
247
- projectTitle: "Demo App",
248
- changeName,
249
- changeDir,
250
- status: "running",
251
- phase: "implementing",
252
- workerAgentId: "codex",
253
- currentTask: "1.2 Second task",
254
- taskCounts: { total: 2, complete: 1, remaining: 1 },
255
- planningJournal: { dirty: false, entryCount: 0 },
256
- execution: {
257
- mode: "apply",
258
- action: "work",
259
- state: "running",
260
- workerAgentId: "codex",
261
- workerSlot: "primary",
262
- armedAt: new Date(Date.now() - 120_000).toISOString(),
263
- startedAt: new Date(Date.now() - 90_000).toISOString(),
264
- sessionKey: "clawspec:live-session",
265
- lastHeartbeatAt: new Date(Date.now() - 15_000).toISOString(),
266
- progressOffset: existingProgress.length,
267
- },
268
- }));
269
-
270
- const manager = new WatcherManager({
271
- stateStore,
272
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
273
- archiveDirName: "archives",
274
- logger: createLogger(),
275
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
276
- acpClient: fakeAcpClient as any,
277
- pollIntervalMs: 25,
278
- });
279
-
280
- setTimeout(() => {
281
- void (async () => {
282
- const doneEvent = JSON.stringify({
283
- version: 1,
284
- timestamp: new Date().toISOString(),
285
- kind: "task_done",
286
- current: 2,
287
- total: 2,
288
- taskId: "1.2",
289
- message: "Done 1.2: finished after gateway recovery. Changed 1 files: openspec/changes/adopt-live-recovery/tasks.md. Next: done.",
290
- });
291
- await writeUtf8(repoStatePaths.workerProgressFile, `${existingProgress}${doneEvent}\n`);
292
- await writeUtf8(tasksPath, "- [x] 1.1 First task\n- [x] 1.2 Second task\n");
293
- await writeJsonFile(repoStatePaths.executionResultFile, {
294
- version: 1,
295
- changeName,
296
- mode: "apply",
297
- status: "done",
298
- timestamp: new Date().toISOString(),
299
- summary: "Completed task 1.2.",
300
- progressMade: true,
301
- completedTask: "1.2 Second task",
302
- changedFiles: ["openspec/changes/adopt-live-recovery/tasks.md"],
303
- notes: ["Task completed after live-session recovery."],
304
- taskCounts: { total: 2, complete: 2, remaining: 0 },
305
- remainingTasks: 0,
306
- });
307
- })();
308
- }, 120);
309
-
310
- await manager.start();
311
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
312
- await manager.stop();
313
-
314
- const project = await stateStore.getActiveProject(channelKey);
315
- assert.equal(project?.status, "done");
316
- assert.equal(project?.phase, "validating");
317
- assert.equal(runTurnCount, 0);
318
- assert.equal(cancelCount >= 1, true);
319
- assert.equal(
320
- notifierMessages.some((m) =>
321
- m.includes("Gateway restarted")
322
- && (m.includes("Reattached") || m.includes("Waiting for the next worker update"))
323
- ),
324
- true,
325
- );
326
- assert.equal(notifierMessages.some((m) => m.includes("Done 1.2: finished after gateway recovery")), true);
327
- assert.equal(notifierMessages.some((m) => m.includes("Start 1.2: second task already resumed before gateway crash")), false);
328
- assert.equal(notifierMessages.some((m) => m.includes("Watcher active. Starting codex worker")), false);
329
- });
330
-
331
- test("recovery from recoverable blocked implementation state", async () => {
332
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-blocked-"));
333
- const workspacePath = path.join(tempRoot, "workspace");
334
- const repoPath = path.join(workspacePath, "demo-app");
335
- const changeName = "blocked-recovery";
336
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
337
- const tasksPath = path.join(changeDir, "tasks.md");
338
- const outputPath = path.join(repoPath, "src", "recovered.txt");
339
- await mkdir(path.join(repoPath, "src"), { recursive: true });
340
- await mkdir(changeDir, { recursive: true });
341
- await writeUtf8(tasksPath, "- [ ] 1.1 Resume after backend startup race\n");
342
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
343
-
344
- const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
345
- await rollbackStore.initializeBaseline();
346
-
347
- const stateStore = new ProjectStateStore(tempRoot, "archives");
348
- await stateStore.initialize();
349
- const notifierMessages: string[] = [];
350
-
351
- const fakeAcpClient = {
352
- agentId: "codex",
353
- runTurn: async () => {
354
- await writeUtf8(tasksPath, "- [x] 1.1 Resume after backend startup race\n");
355
- await writeUtf8(outputPath, "recovered\n");
356
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
357
- version: 1,
358
- changeName,
359
- mode: "apply",
360
- status: "done",
361
- timestamp: new Date().toISOString(),
362
- summary: "Recovered from blocked startup state.",
363
- progressMade: true,
364
- completedTask: "1.1 Resume after backend startup race",
365
- changedFiles: ["src/recovered.txt"],
366
- notes: ["Recovered after ACP runtime backend became ready."],
367
- taskCounts: { total: 1, complete: 1, remaining: 0 },
368
- remainingTasks: 0,
369
- });
370
- },
371
- cancelSession: async () => undefined,
372
- closeSession: async () => undefined,
373
- };
374
-
375
- const channelKey = "discord:blocked-recovery:default:main";
376
- await stateStore.createProject(channelKey);
377
- await stateStore.updateProject(channelKey, (current) => ({
378
- ...current,
379
- workspacePath,
380
- repoPath,
381
- projectName: "demo-app",
382
- projectTitle: "Demo App",
383
- changeName,
384
- changeDir,
385
- status: "blocked",
386
- phase: "implementing",
387
- workerAgentId: "codex",
388
- currentTask: "1.1 Resume after backend startup race",
389
- taskCounts: { total: 1, complete: 0, remaining: 1 },
390
- latestSummary: "Execution failed: ACP runtime backend is currently unavailable. Try again in a moment.",
391
- blockedReason: "ACP runtime backend is currently unavailable. Try again in a moment.",
392
- planningJournal: { dirty: false, entryCount: 0 },
393
- rollback: {
394
- baselineRoot: rollbackStore.baselineRoot,
395
- manifestPath: rollbackStore.manifestPath,
396
- snapshotReady: true,
397
- touchedFileCount: 0,
398
- },
399
- execution: undefined,
400
- lastExecution: {
401
- version: 1,
402
- changeName,
403
- mode: "apply",
404
- status: "blocked",
405
- timestamp: new Date().toISOString(),
406
- summary: "Execution failed: ACP runtime backend is currently unavailable. Try again in a moment.",
407
- progressMade: false,
408
- changedFiles: [],
409
- notes: ["ACP runtime backend is currently unavailable. Try again in a moment."],
410
- blocker: "ACP runtime backend is currently unavailable. Try again in a moment.",
411
- taskCounts: { total: 1, complete: 0, remaining: 1 },
412
- remainingTasks: 1,
413
- },
414
- lastExecutionAt: new Date().toISOString(),
415
- }));
416
-
417
- const manager = new WatcherManager({
418
- stateStore,
419
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
420
- archiveDirName: "archives",
421
- logger: createLogger(),
422
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
423
- acpClient: fakeAcpClient as any,
424
- pollIntervalMs: 25,
425
- });
426
-
427
- await manager.start();
428
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
429
- await manager.stop();
430
-
431
- const project = await stateStore.getActiveProject(channelKey);
432
- assert.equal(project?.status, "done");
433
- assert.equal(project?.phase, "validating");
434
- assert.equal(notifierMessages.some((m) => m.includes("Gateway restarted")), true);
435
- assert.equal(notifierMessages.some((m) => m.includes("All tasks complete")), true);
436
- });
437
-
438
- test("rearm never drops execution field (batch mode)", async () => {
439
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-rearm-safe-"));
440
- const workspacePath = path.join(tempRoot, "workspace");
441
- const repoPath = path.join(workspacePath, "demo-app");
442
- const changeName = "rearm-safe";
443
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
444
- const tasksPath = path.join(changeDir, "tasks.md");
445
- await mkdir(path.join(repoPath, "src"), { recursive: true });
446
- await mkdir(changeDir, { recursive: true });
447
- await writeUtf8(tasksPath, "- [ ] 1.1 Build it\n- [ ] 1.2 Test it\n");
448
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
449
-
450
- const rollbackStore = new RollbackStore(repoPath, "archives", changeName);
451
- await rollbackStore.initializeBaseline();
452
-
453
- const stateStore = new ProjectStateStore(tempRoot, "archives");
454
- await stateStore.initialize();
455
- let turnCount = 0;
456
-
457
- const fakeAcpClient = {
458
- agentId: "codex",
459
- runTurn: async () => {
460
- turnCount += 1;
461
- await writeUtf8(tasksPath, "- [x] 1.1 Build it\n- [x] 1.2 Test it\n");
462
- await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
463
- version: 1, changeName, mode: "apply", status: "done",
464
- timestamp: new Date().toISOString(), summary: "Completed 2 tasks.",
465
- progressMade: true, completedTask: "1.2 Test it",
466
- changedFiles: [], notes: ["Completed all tasks in batch."],
467
- taskCounts: { total: 2, complete: 2, remaining: 0 },
468
- });
469
- },
470
- cancelSession: async () => undefined,
471
- closeSession: async () => undefined,
472
- };
473
-
474
- const channelKey = "discord:rearm-safe:default:main";
475
- await stateStore.createProject(channelKey);
476
- await stateStore.updateProject(channelKey, (current) => ({
477
- ...current,
478
- workspacePath, repoPath, projectName: "demo-app", projectTitle: "Demo App",
479
- changeName, changeDir, status: "armed", phase: "implementing",
480
- currentTask: "1.1 Build it",
481
- taskCounts: { total: 2, complete: 0, remaining: 2 },
482
- planningJournal: { dirty: false, entryCount: 0 },
483
- rollback: {
484
- baselineRoot: rollbackStore.baselineRoot,
485
- manifestPath: rollbackStore.manifestPath,
486
- snapshotReady: true, touchedFileCount: 0,
487
- },
488
- execution: {
489
- mode: "apply", action: "work", state: "armed", armedAt: new Date().toISOString(),
490
- },
491
- }));
492
-
493
- const manager = new WatcherManager({
494
- stateStore,
495
- openSpec: createWorkOpenSpec(changeDir, tasksPath),
496
- archiveDirName: "archives",
497
- logger: createLogger(),
498
- notifier: { send: async () => undefined } as any,
499
- acpClient: fakeAcpClient as any,
500
- pollIntervalMs: 25,
501
- });
502
-
503
- await manager.start();
504
- await manager.wake(channelKey);
505
- await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "done");
506
- await manager.stop();
507
-
508
- const project = await stateStore.getActiveProject(channelKey);
509
- assert.equal(project?.status, "done");
510
- assert.equal(project?.phase, "validating");
511
- assert.equal(turnCount, 1);
512
- });
513
-
514
- test("startup recovery does not spawn a worker for visible chat planning", async () => {
515
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-recovery-visible-plan-"));
516
- const workspacePath = path.join(tempRoot, "workspace");
517
- const repoPath = path.join(workspacePath, "demo-app");
518
- const changeName = "visible-plan";
519
- const changeDir = path.join(repoPath, "openspec", "changes", changeName);
520
- await mkdir(changeDir, { recursive: true });
521
- await writeUtf8(path.join(changeDir, "proposal.md"), "# Proposal\n");
522
-
523
- const stateStore = new ProjectStateStore(tempRoot, "archives");
524
- await stateStore.initialize();
525
- const notifierMessages: string[] = [];
526
- let runCount = 0;
527
-
528
- const fakeAcpClient = {
529
- agentId: "codex",
530
- runTurn: async () => {
531
- runCount += 1;
532
- },
533
- cancelSession: async () => undefined,
534
- closeSession: async () => undefined,
535
- };
536
-
537
- const channelKey = "discord:visible-plan:default:main";
538
- await stateStore.createProject(channelKey);
539
- await stateStore.updateProject(channelKey, (current) => ({
540
- ...current,
541
- workspacePath,
542
- repoPath,
543
- projectName: "demo-app",
544
- projectTitle: "Demo App",
545
- changeName,
546
- changeDir,
547
- status: "planning",
548
- phase: "planning_sync",
549
- planningJournal: {
550
- dirty: true,
551
- entryCount: 1,
552
- lastEntryAt: new Date(Date.now() - 60_000).toISOString(),
553
- },
554
- execution: undefined,
555
- }));
556
-
557
- const manager = new WatcherManager({
558
- stateStore,
559
- openSpec: createWorkOpenSpec(changeDir, path.join(changeDir, "tasks.md")),
560
- archiveDirName: "archives",
561
- logger: createLogger(),
562
- notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
563
- acpClient: fakeAcpClient as any,
564
- pollIntervalMs: 25,
565
- });
566
-
567
- await manager.start();
568
- await new Promise((resolve) => setTimeout(resolve, 150));
569
- await manager.stop();
570
-
571
- const project = await stateStore.getActiveProject(channelKey);
572
- assert.equal(runCount, 0);
573
- assert.equal(project?.status, "planning");
574
- assert.equal(project?.phase, "planning_sync");
575
- assert.equal(notifierMessages.length, 0);
576
- });
@@ -1,87 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { mkdtemp, mkdir } from "node:fs/promises";
6
- import { ProjectMemoryStore } from "../src/memory/store.ts";
7
- import { ClawSpecService } from "../src/orchestrator/service.ts";
8
- import { ProjectStateStore } from "../src/state/store.ts";
9
- import { pathExists, writeUtf8 } from "../src/utils/fs.ts";
10
- import { WorkspaceStore } from "../src/workspace/store.ts";
11
-
12
- test("service writes archive bundles from visible-execution state", async () => {
13
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-service-"));
14
- const repoPath = path.join(tempRoot, "repo");
15
- const workspacePath = path.join(tempRoot, "workspace");
16
- const changeName = "demo-change";
17
- await mkdir(path.join(repoPath, "openspec", "changes", changeName), { recursive: true });
18
- await mkdir(workspacePath, { recursive: true });
19
-
20
- await writeUtf8(
21
- path.join(repoPath, "openspec", "changes", changeName, "tasks.md"),
22
- [
23
- "## 1. Setup",
24
- "",
25
- "- [x] 1.1 Create plugin",
26
- "- [ ] 1.2 Add lifecycle store",
27
- "",
28
- ].join("\n"),
29
- );
30
-
31
- const stateStore = new ProjectStateStore(tempRoot, "archives");
32
- const memoryStore = new ProjectMemoryStore(path.join(tempRoot, "memory.json"));
33
- const workspaceStore = new WorkspaceStore(path.join(tempRoot, "workspace-state.json"), workspacePath);
34
- await stateStore.initialize();
35
- await memoryStore.initialize();
36
- await workspaceStore.initialize();
37
-
38
- let project = await stateStore.createProject("channel:demo");
39
- project = await stateStore.updateProject("channel:demo", (current) => ({
40
- ...current,
41
- workspacePath,
42
- repoPath,
43
- projectName: "repo",
44
- changeName,
45
- changeDir: path.join(repoPath, "openspec", "changes", changeName),
46
- latestSummary: "Latest summary text",
47
- }));
48
-
49
- const fakeApi = {
50
- logger: {
51
- info: () => undefined,
52
- warn: () => undefined,
53
- error: () => undefined,
54
- debug: () => undefined,
55
- },
56
- } as any;
57
-
58
- const service = new ClawSpecService({
59
- api: fakeApi,
60
- config: {
61
- acp: {
62
- backend: "acpx",
63
- defaultAgent: "codex",
64
- },
65
- } as any,
66
- logger: fakeApi.logger,
67
- stateStore,
68
- memoryStore,
69
- openSpec: {} as any,
70
- archiveDirName: "archives",
71
- defaultWorkspace: workspacePath,
72
- defaultWorkerAgentId: undefined,
73
- workspaceStore,
74
- });
75
-
76
- await (service as any).ensureProjectSupportFiles(project);
77
- const counts = await (service as any).loadTaskCounts(project);
78
- assert.equal(counts.complete, 1);
79
- assert.equal(counts.remaining, 1);
80
-
81
- const archivePath = await (service as any).writeArchiveBundle(project, counts);
82
- assert.equal(await pathExists(path.join(archivePath, "resume-context.md")), true);
83
- assert.equal(await pathExists(path.join(archivePath, "session-summary.md")), true);
84
- assert.equal(await pathExists(path.join(archivePath, "changed-files.md")), true);
85
- assert.equal(await pathExists(path.join(archivePath, "decision-log.md")), true);
86
- assert.equal(await pathExists(path.join(archivePath, "run-metadata.json")), true);
87
- });