clawspec 1.0.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 (71) hide show
  1. package/README.md +908 -0
  2. package/README.zh-CN.md +914 -0
  3. package/index.ts +3 -0
  4. package/openclaw.plugin.json +129 -0
  5. package/package.json +52 -0
  6. package/skills/openspec-apply-change.md +146 -0
  7. package/skills/openspec-explore.md +75 -0
  8. package/skills/openspec-propose.md +102 -0
  9. package/src/acp/client.ts +693 -0
  10. package/src/config.ts +220 -0
  11. package/src/control/keywords.ts +72 -0
  12. package/src/dependencies/acpx.ts +221 -0
  13. package/src/dependencies/openspec.ts +148 -0
  14. package/src/execution/session.ts +56 -0
  15. package/src/execution/state.ts +125 -0
  16. package/src/index.ts +179 -0
  17. package/src/memory/store.ts +118 -0
  18. package/src/openspec/cli.ts +279 -0
  19. package/src/openspec/tasks.ts +40 -0
  20. package/src/orchestrator/helpers.ts +312 -0
  21. package/src/orchestrator/service.ts +2971 -0
  22. package/src/planning/journal.ts +118 -0
  23. package/src/rollback/store.ts +173 -0
  24. package/src/state/locks.ts +133 -0
  25. package/src/state/store.ts +527 -0
  26. package/src/types.ts +301 -0
  27. package/src/utils/args.ts +88 -0
  28. package/src/utils/channel-key.ts +66 -0
  29. package/src/utils/env-path.ts +31 -0
  30. package/src/utils/fs.ts +218 -0
  31. package/src/utils/markdown.ts +136 -0
  32. package/src/utils/messages.ts +5 -0
  33. package/src/utils/paths.ts +127 -0
  34. package/src/utils/shell-command.ts +227 -0
  35. package/src/utils/slug.ts +50 -0
  36. package/src/watchers/manager.ts +3042 -0
  37. package/src/watchers/notifier.ts +69 -0
  38. package/src/worker/prompts.ts +484 -0
  39. package/src/worker/skills.ts +52 -0
  40. package/src/workspace/store.ts +140 -0
  41. package/test/acp-client.test.ts +234 -0
  42. package/test/acpx-dependency.test.ts +112 -0
  43. package/test/assistant-journal.test.ts +136 -0
  44. package/test/command-surface.test.ts +23 -0
  45. package/test/config.test.ts +77 -0
  46. package/test/detach-attach.test.ts +98 -0
  47. package/test/file-lock.test.ts +78 -0
  48. package/test/fs-utils.test.ts +22 -0
  49. package/test/helpers/harness.ts +241 -0
  50. package/test/helpers.test.ts +108 -0
  51. package/test/keywords.test.ts +80 -0
  52. package/test/notifier.test.ts +29 -0
  53. package/test/openspec-dependency.test.ts +67 -0
  54. package/test/pause-cancel.test.ts +55 -0
  55. package/test/planning-journal.test.ts +69 -0
  56. package/test/plugin-registration.test.ts +35 -0
  57. package/test/project-memory.test.ts +42 -0
  58. package/test/proposal.test.ts +24 -0
  59. package/test/queue-planning.test.ts +247 -0
  60. package/test/queue-work.test.ts +110 -0
  61. package/test/recovery.test.ts +576 -0
  62. package/test/service-archive.test.ts +82 -0
  63. package/test/shell-command.test.ts +48 -0
  64. package/test/state-store.test.ts +74 -0
  65. package/test/tasks-and-checkpoint.test.ts +60 -0
  66. package/test/use-project.test.ts +19 -0
  67. package/test/watcher-planning.test.ts +504 -0
  68. package/test/watcher-work.test.ts +1741 -0
  69. package/test/worker-command.test.ts +66 -0
  70. package/test/worker-skills.test.ts +12 -0
  71. package/tsconfig.json +25 -0
@@ -0,0 +1,110 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "node:path";
4
+ import { buildWorkerSessionKey, createWorkerSessionKey } from "../src/execution/session.ts";
5
+ import { writeUtf8 } from "../src/utils/fs.ts";
6
+ import { createServiceHarness, seedPlanningProject } from "./helpers/harness.ts";
7
+
8
+ test("work queues background implementation", async () => {
9
+ const harness = await createServiceHarness("clawspec-work-queue-");
10
+ const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
11
+ const channelKey = "discord:work-queue:default:main";
12
+ const tasksPath = path.join(changeDir, "tasks.md");
13
+ await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
14
+
15
+ harness.openSpec.instructionsApply = async (cwd: string, changeName: string) => ({
16
+ command: `openspec instructions apply --change ${changeName} --json`,
17
+ cwd,
18
+ stdout: "{}",
19
+ stderr: "",
20
+ durationMs: 1,
21
+ parsed: {
22
+ changeName,
23
+ changeDir,
24
+ schemaName: "spec-driven",
25
+ contextFiles: { tasks: tasksPath },
26
+ progress: { total: 1, complete: 0, remaining: 1 },
27
+ tasks: [{ id: "1.1", description: "Build the demo endpoint", done: false }],
28
+ state: "ready",
29
+ instruction: "Implement the remaining task.",
30
+ },
31
+ });
32
+
33
+ await seedPlanningProject(stateStore, channelKey, {
34
+ workspacePath,
35
+ repoPath,
36
+ projectName: "demo-app",
37
+ changeName: "queue-work",
38
+ changeDir,
39
+ phase: "tasks",
40
+ status: "ready",
41
+ planningDirty: false,
42
+ });
43
+ await stateStore.updateProject(channelKey, (current) => ({
44
+ ...current,
45
+ boundSessionKey: "agent:main:discord:channel:work-queue",
46
+ }));
47
+
48
+ const result = await service.queueWorkProject(channelKey, "apply");
49
+ const project = await stateStore.getActiveProject(channelKey);
50
+
51
+ assert.match(result.text ?? "", /Execution Queued/);
52
+ assert.equal(project?.status, "armed");
53
+ assert.equal(project?.execution?.action, "work");
54
+ assert.equal(project?.currentTask, "1.1 Build the demo endpoint");
55
+ assert.equal(
56
+ project?.execution?.sessionKey,
57
+ createWorkerSessionKey(project!, {
58
+ workerSlot: "primary",
59
+ workerAgentId: "codex",
60
+ attemptKey: project?.execution?.armedAt,
61
+ }),
62
+ );
63
+ assert.match(project?.execution?.sessionKey ?? "", new RegExp(`^${buildWorkerSessionKey(project!, "primary", "codex")}:`));
64
+ assert.notEqual(project?.execution?.sessionKey, project?.boundSessionKey);
65
+ assert.deepEqual(watcherManager.wakeCalls, [channelKey]);
66
+ });
67
+
68
+ test("main chat agent end does not clear a background worker run", async () => {
69
+ const harness = await createServiceHarness("clawspec-work-session-");
70
+ const { service, stateStore, repoPath, workspacePath, changeDir } = harness;
71
+ const channelKey = "discord:work-session:default:main";
72
+ const workerSessionKey = "clawspec:worker-session";
73
+ const boundSessionKey = "agent:main:discord:channel:work-session";
74
+
75
+ await seedPlanningProject(stateStore, channelKey, {
76
+ workspacePath,
77
+ repoPath,
78
+ projectName: "demo-app",
79
+ changeName: "queue-work",
80
+ changeDir,
81
+ phase: "implementing",
82
+ status: "running",
83
+ planningDirty: false,
84
+ execution: { action: "work", state: "running", mode: "apply" },
85
+ });
86
+ await stateStore.updateProject(channelKey, (current) => ({
87
+ ...current,
88
+ boundSessionKey,
89
+ latestSummary: "Worker is running in the background.",
90
+ execution: current.execution
91
+ ? {
92
+ ...current.execution,
93
+ sessionKey: workerSessionKey,
94
+ workerAgentId: "codex",
95
+ workerSlot: "primary",
96
+ }
97
+ : current.execution,
98
+ }));
99
+
100
+ await service.handleAgentEnd(
101
+ { messages: [], success: true },
102
+ { sessionKey: boundSessionKey, trigger: "user" },
103
+ );
104
+
105
+ const project = await stateStore.getActiveProject(channelKey);
106
+ assert.equal(project?.status, "running");
107
+ assert.equal(project?.execution?.state, "running");
108
+ assert.equal(project?.execution?.sessionKey, workerSessionKey);
109
+ assert.equal(project?.latestSummary, "Worker is running in the background.");
110
+ });
@@ -0,0 +1,576 @@
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
+ });