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,504 @@
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, writeJsonFile, writeUtf8 } from "../src/utils/fs.ts";
7
+ import { PlanningJournalStore } from "../src/planning/journal.ts";
8
+ import { getRepoStatePaths } from "../src/utils/paths.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 createPlanningOpenSpec(changeDir: string, proposalPath: string) {
14
+ return {
15
+ status: async (cwd: string, changeName: string) => ({
16
+ command: `openspec status --change ${changeName} --json`,
17
+ cwd,
18
+ stdout: "{}",
19
+ stderr: "",
20
+ durationMs: 1,
21
+ parsed: {
22
+ changeName,
23
+ schemaName: "spec-driven",
24
+ isComplete: await pathExists(proposalPath),
25
+ applyRequires: ["proposal"],
26
+ artifacts: [
27
+ {
28
+ id: "proposal",
29
+ outputPath: proposalPath,
30
+ status: await pathExists(proposalPath) ? "done" : "ready",
31
+ },
32
+ ],
33
+ },
34
+ }),
35
+ instructionsArtifact: async (cwd: string, artifactId: string, changeName: string) => ({
36
+ command: `openspec instructions ${artifactId} --change ${changeName} --json`,
37
+ cwd,
38
+ stdout: "{}",
39
+ stderr: "",
40
+ durationMs: 1,
41
+ parsed: {
42
+ changeName,
43
+ artifactId,
44
+ schemaName: "spec-driven",
45
+ changeDir,
46
+ outputPath: proposalPath,
47
+ description: "proposal",
48
+ instruction: "Write the proposal artifact.",
49
+ template: "# Proposal\n",
50
+ dependencies: [],
51
+ unlocks: ["apply"],
52
+ },
53
+ }),
54
+ instructionsApply: async (cwd: string, changeName: string) => ({
55
+ command: `openspec instructions apply --change ${changeName} --json`,
56
+ cwd,
57
+ stdout: "{}",
58
+ stderr: "",
59
+ durationMs: 1,
60
+ parsed: {
61
+ changeName,
62
+ changeDir,
63
+ schemaName: "spec-driven",
64
+ contextFiles: { proposal: proposalPath },
65
+ progress: { total: 1, complete: 0, remaining: 1 },
66
+ tasks: [{ id: "1.1", description: "Build demo", done: false }],
67
+ state: await pathExists(proposalPath) ? "ready" : "blocked",
68
+ instruction: "Implement the remaining task.",
69
+ },
70
+ }),
71
+ } as any;
72
+ }
73
+
74
+ test("watcher planning flow completes", async () => {
75
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-"));
76
+ const workspacePath = path.join(tempRoot, "workspace");
77
+ const repoPath = path.join(workspacePath, "demo-app");
78
+ const changeName = "watch-plan";
79
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
80
+ const proposalPath = path.join(changeDir, "proposal.md");
81
+ await mkdir(changeDir, { recursive: true });
82
+ await writeUtf8(path.join(changeDir, ".openspec.yaml"), "schema: spec-driven\n");
83
+
84
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
85
+ await stateStore.initialize();
86
+ const notifierMessages: string[] = [];
87
+
88
+ const fakeAcpClient = {
89
+ runTurn: async () => {
90
+ await writeUtf8(proposalPath, "# Proposal\n\nGenerated by watcher.\n");
91
+ await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
92
+ version: 1,
93
+ changeName,
94
+ mode: "apply",
95
+ status: "running",
96
+ timestamp: new Date().toISOString(),
97
+ summary: "Updated proposal.",
98
+ progressMade: true,
99
+ currentArtifact: "proposal",
100
+ changedFiles: ["openspec/changes/watch-plan/proposal.md"],
101
+ notes: ["Proposal refreshed"],
102
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
103
+ });
104
+ },
105
+ cancelSession: async () => undefined,
106
+ closeSession: async () => undefined,
107
+ };
108
+
109
+ const manager = new WatcherManager({
110
+ stateStore,
111
+ openSpec: createPlanningOpenSpec(changeDir, proposalPath),
112
+ archiveDirName: "archives",
113
+ logger: createLogger(),
114
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
115
+ acpClient: fakeAcpClient as any,
116
+ pollIntervalMs: 25,
117
+ });
118
+
119
+ const channelKey = "discord:watch-plan:default:main";
120
+ await stateStore.createProject(channelKey);
121
+ await stateStore.updateProject(channelKey, (current) => ({
122
+ ...current,
123
+ workspacePath,
124
+ repoPath,
125
+ projectName: "demo-app",
126
+ projectTitle: "Demo App",
127
+ changeName,
128
+ changeDir,
129
+ status: "planning",
130
+ phase: "planning_sync",
131
+ planningJournal: {
132
+ dirty: true,
133
+ entryCount: 1,
134
+ lastEntryAt: new Date(Date.now() - 60_000).toISOString(),
135
+ },
136
+ execution: {
137
+ mode: "apply",
138
+ action: "plan",
139
+ state: "armed",
140
+ armedAt: new Date().toISOString(),
141
+ },
142
+ }));
143
+
144
+ await manager.start();
145
+ await manager.wake(channelKey);
146
+ await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready");
147
+ await manager.stop();
148
+
149
+ const project = await stateStore.getActiveProject(channelKey);
150
+ assert.equal(project?.status, "ready");
151
+ assert.equal(project?.phase, "tasks");
152
+ assert.equal(project?.planningJournal?.dirty, false);
153
+ assert.equal(await pathExists(proposalPath), true);
154
+ assert.equal(notifierMessages.some((m) => m.includes("Planning") && m.includes("proposal")), true);
155
+ assert.equal(notifierMessages.some((m) => m.includes("Planning ready") && m.includes("Next: run `cs-work` to start implementation")), true);
156
+ });
157
+
158
+ test("planning fallback normalizes artifact path", async () => {
159
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-fallback-"));
160
+ const workspacePath = path.join(tempRoot, "workspace");
161
+ const repoPath = path.join(workspacePath, "demo-app");
162
+ const changeName = "watch-plan-fallback";
163
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
164
+ const proposalPath = path.join(changeDir, "proposal.md");
165
+ await mkdir(changeDir, { recursive: true });
166
+
167
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
168
+ await stateStore.initialize();
169
+
170
+ // Use relative outputPath to test normalization
171
+ const fakeOpenSpec = {
172
+ status: async (cwd: string, cn: string) => ({
173
+ command: `openspec status --change ${cn} --json`,
174
+ cwd, stdout: "{}", stderr: "", durationMs: 1,
175
+ parsed: {
176
+ changeName: cn,
177
+ schemaName: "spec-driven",
178
+ isComplete: await pathExists(proposalPath),
179
+ applyRequires: ["proposal"],
180
+ artifacts: [{ id: "proposal", outputPath: "proposal.md", status: await pathExists(proposalPath) ? "done" : "ready" }],
181
+ },
182
+ }),
183
+ instructionsArtifact: async (cwd: string, artifactId: string, cn: string) => ({
184
+ command: `openspec instructions ${artifactId} --change ${cn} --json`,
185
+ cwd, stdout: "{}", stderr: "", durationMs: 1,
186
+ parsed: {
187
+ changeName: cn, artifactId, schemaName: "spec-driven", changeDir,
188
+ outputPath: "proposal.md",
189
+ description: "proposal", instruction: "Write the proposal artifact.",
190
+ template: "# Proposal\n", dependencies: [], unlocks: ["apply"],
191
+ },
192
+ }),
193
+ instructionsApply: async (cwd: string, cn: string) => ({
194
+ command: `openspec instructions apply --change ${cn} --json`,
195
+ cwd, stdout: "{}", stderr: "", durationMs: 1,
196
+ parsed: {
197
+ changeName: cn, changeDir, schemaName: "spec-driven",
198
+ contextFiles: { proposal: proposalPath },
199
+ progress: { total: 1, complete: 0, remaining: 1 },
200
+ tasks: [{ id: "1.1", description: "Build demo", done: false }],
201
+ state: await pathExists(proposalPath) ? "ready" : "blocked",
202
+ instruction: "Implement the remaining task.",
203
+ },
204
+ }),
205
+ } as any;
206
+
207
+ const manager = new WatcherManager({
208
+ stateStore,
209
+ openSpec: fakeOpenSpec,
210
+ archiveDirName: "archives",
211
+ logger: createLogger(),
212
+ notifier: { send: async () => undefined } as any,
213
+ acpClient: {
214
+ runTurn: async () => { await writeUtf8(proposalPath, "# Proposal\n"); },
215
+ cancelSession: async () => undefined,
216
+ closeSession: async () => undefined,
217
+ } as any,
218
+ pollIntervalMs: 25,
219
+ });
220
+
221
+ const channelKey = "discord:watch-plan-fallback:default:main";
222
+ await stateStore.createProject(channelKey);
223
+ await stateStore.updateProject(channelKey, (current) => ({
224
+ ...current,
225
+ workspacePath, repoPath, projectName: "demo-app", projectTitle: "Demo App",
226
+ changeName, changeDir, status: "planning", phase: "planning_sync",
227
+ planningJournal: { dirty: true, entryCount: 1, lastEntryAt: new Date(Date.now() - 60_000).toISOString() },
228
+ execution: { mode: "apply", action: "plan", state: "armed", armedAt: new Date().toISOString() },
229
+ }));
230
+
231
+ await manager.start();
232
+ await manager.wake(channelKey);
233
+ await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready");
234
+ await manager.stop();
235
+
236
+ const project = await stateStore.getActiveProject(channelKey);
237
+ assert.deepEqual(project?.lastExecution?.changedFiles, ["openspec/changes/watch-plan-fallback/proposal.md"]);
238
+ });
239
+
240
+ test("watcher force-refreshes planning artifacts when journal is dirty and OpenSpec reports all artifacts done", async () => {
241
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-forced-"));
242
+ const workspacePath = path.join(tempRoot, "workspace");
243
+ const repoPath = path.join(workspacePath, "demo-app");
244
+ const changeName = "watch-plan-forced";
245
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
246
+ const proposalPath = path.join(changeDir, "proposal.md");
247
+ const designPath = path.join(changeDir, "design.md");
248
+ const specPath = path.join(changeDir, "specs", "city-info-api", "spec.md");
249
+ const tasksPath = path.join(changeDir, "tasks.md");
250
+ await mkdir(path.dirname(specPath), { recursive: true });
251
+ await writeUtf8(path.join(changeDir, ".openspec.yaml"), "schema: spec-driven\n");
252
+ await writeUtf8(proposalPath, "# Proposal\n\nOld proposal.\n");
253
+ await writeUtf8(designPath, "# Design\n\nOld design.\n");
254
+ await writeUtf8(specPath, "# Spec\n\nOld spec.\n");
255
+ await writeUtf8(tasksPath, "- [x] 1.1 Old task\n");
256
+
257
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
258
+ await stateStore.initialize();
259
+ const notifierMessages: string[] = [];
260
+ const runOrder: string[] = [];
261
+
262
+ const outputPaths: Record<string, string> = {
263
+ proposal: proposalPath,
264
+ specs: specPath,
265
+ design: designPath,
266
+ tasks: tasksPath,
267
+ };
268
+
269
+ const fakeOpenSpec = {
270
+ status: async (cwd: string, cn: string) => ({
271
+ command: `openspec status --change ${cn} --json`,
272
+ cwd, stdout: "{}", stderr: "", durationMs: 1,
273
+ parsed: {
274
+ changeName: cn,
275
+ schemaName: "spec-driven",
276
+ isComplete: true,
277
+ applyRequires: ["tasks"],
278
+ artifacts: [
279
+ { id: "proposal", outputPath: proposalPath, status: "done" },
280
+ { id: "specs", outputPath: specPath, status: "done" },
281
+ { id: "design", outputPath: designPath, status: "done" },
282
+ { id: "tasks", outputPath: tasksPath, status: "done" },
283
+ ],
284
+ },
285
+ }),
286
+ instructionsArtifact: async (cwd: string, artifactId: string, cn: string) => ({
287
+ command: `openspec instructions ${artifactId} --change ${cn} --json`,
288
+ cwd, stdout: "{}", stderr: "", durationMs: 1,
289
+ parsed: {
290
+ changeName: cn,
291
+ artifactId,
292
+ schemaName: "spec-driven",
293
+ changeDir,
294
+ outputPath: outputPaths[artifactId]!,
295
+ description: artifactId,
296
+ instruction: `Write ${artifactId}.`,
297
+ template: `# ${artifactId}\n`,
298
+ dependencies: [],
299
+ unlocks: ["apply"],
300
+ },
301
+ }),
302
+ instructionsApply: async (cwd: string, cn: string) => ({
303
+ command: `openspec instructions apply --change ${cn} --json`,
304
+ cwd, stdout: "{}", stderr: "", durationMs: 1,
305
+ parsed: {
306
+ changeName: cn,
307
+ changeDir,
308
+ schemaName: "spec-driven",
309
+ contextFiles: {
310
+ proposal: proposalPath,
311
+ specs: specPath,
312
+ design: designPath,
313
+ tasks: tasksPath,
314
+ },
315
+ progress: { total: 1, complete: 0, remaining: 1 },
316
+ tasks: [{ id: "1.1", description: "Implement refreshed task", done: false }],
317
+ state: "ready",
318
+ instruction: "Implement the refreshed task.",
319
+ },
320
+ }),
321
+ } as any;
322
+
323
+ const fakeAcpClient = {
324
+ runTurn: async ({ text }: { text: string }) => {
325
+ const match = text.match(/Artifact:\s+([a-z-]+)/i);
326
+ const artifactId = match?.[1];
327
+ if (!artifactId || !outputPaths[artifactId]) {
328
+ throw new Error(`Missing artifact id in prompt: ${text}`);
329
+ }
330
+ runOrder.push(artifactId);
331
+ if (artifactId === "tasks") {
332
+ await writeUtf8(tasksPath, "- [ ] 1.1 Implement refreshed task\n");
333
+ } else {
334
+ await writeUtf8(outputPaths[artifactId], `# ${artifactId}\n\nRefreshed ${artifactId}.\n`);
335
+ }
336
+ await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
337
+ version: 1,
338
+ changeName,
339
+ mode: "apply",
340
+ status: "running",
341
+ timestamp: new Date().toISOString(),
342
+ summary: `Updated ${artifactId}.`,
343
+ progressMade: true,
344
+ currentArtifact: artifactId,
345
+ changedFiles: [`openspec/changes/${changeName}/${path.relative(changeDir, outputPaths[artifactId]!).replace(/\\/g, "/")}`],
346
+ notes: [`Refreshed ${artifactId}`],
347
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
348
+ });
349
+ },
350
+ cancelSession: async () => undefined,
351
+ closeSession: async () => undefined,
352
+ };
353
+
354
+ const manager = new WatcherManager({
355
+ stateStore,
356
+ openSpec: fakeOpenSpec,
357
+ archiveDirName: "archives",
358
+ logger: createLogger(),
359
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
360
+ acpClient: fakeAcpClient as any,
361
+ pollIntervalMs: 25,
362
+ });
363
+
364
+ const channelKey = "discord:watch-plan-forced:default:main";
365
+ await stateStore.createProject(channelKey);
366
+ await stateStore.updateProject(channelKey, (current) => ({
367
+ ...current,
368
+ workspacePath,
369
+ repoPath,
370
+ projectName: "demo-app",
371
+ projectTitle: "Demo App",
372
+ changeName,
373
+ changeDir,
374
+ status: "planning",
375
+ phase: "planning_sync",
376
+ planningJournal: {
377
+ dirty: true,
378
+ entryCount: 1,
379
+ lastEntryAt: new Date(Date.now() - 60_000).toISOString(),
380
+ lastSyncedAt: new Date(Date.now() - 120_000).toISOString(),
381
+ },
382
+ execution: {
383
+ mode: "apply",
384
+ action: "plan",
385
+ state: "armed",
386
+ armedAt: new Date().toISOString(),
387
+ },
388
+ }));
389
+
390
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
391
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
392
+ await journalStore.append({
393
+ timestamp: new Date(Date.now() - 60_000).toISOString(),
394
+ changeName,
395
+ role: "user",
396
+ text: "refresh the change to reflect the two new interfaces",
397
+ });
398
+
399
+ await manager.start();
400
+ await manager.wake(channelKey);
401
+ await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready");
402
+ await manager.stop();
403
+
404
+ const project = await stateStore.getActiveProject(channelKey);
405
+ assert.deepEqual(runOrder, ["proposal", "specs", "design", "tasks"]);
406
+ assert.equal(project?.status, "ready");
407
+ assert.equal(project?.planningJournal?.dirty, false);
408
+ assert.equal(await journalStore.hasUnsyncedChanges(changeName, repoStatePaths.planningJournalSnapshotFile), false);
409
+ assert.equal(
410
+ notifierMessages.some((message) => message.includes("Planning ready.") && message.includes("run `cs-work`")),
411
+ true,
412
+ );
413
+ });
414
+
415
+ test("watcher restarts planning worker after ACP runtime exit", async () => {
416
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-watcher-plan-restart-"));
417
+ const workspacePath = path.join(tempRoot, "workspace");
418
+ const repoPath = path.join(workspacePath, "demo-app");
419
+ const changeName = "watch-plan-restart";
420
+ const changeDir = path.join(repoPath, "openspec", "changes", changeName);
421
+ const proposalPath = path.join(changeDir, "proposal.md");
422
+ await mkdir(changeDir, { recursive: true });
423
+ await writeUtf8(path.join(changeDir, ".openspec.yaml"), "schema: spec-driven\n");
424
+
425
+ const stateStore = new ProjectStateStore(tempRoot, "archives");
426
+ await stateStore.initialize();
427
+ const notifierMessages: string[] = [];
428
+ let runCount = 0;
429
+
430
+ const fakeAcpClient = {
431
+ agentId: "codex",
432
+ runTurn: async () => {
433
+ runCount += 1;
434
+ if (runCount === 1) {
435
+ throw new Error("acpx exited with code 1");
436
+ }
437
+ await writeUtf8(proposalPath, "# Proposal\n\nRecovered after restart.\n");
438
+ await writeJsonFile(getRepoStatePaths(repoPath, "archives").executionResultFile, {
439
+ version: 1,
440
+ changeName,
441
+ mode: "apply",
442
+ status: "running",
443
+ timestamp: new Date().toISOString(),
444
+ summary: "Updated proposal.",
445
+ progressMade: true,
446
+ currentArtifact: "proposal",
447
+ changedFiles: ["openspec/changes/watch-plan-restart/proposal.md"],
448
+ notes: ["Proposal refreshed"],
449
+ taskCounts: { total: 1, complete: 0, remaining: 1 },
450
+ });
451
+ },
452
+ cancelSession: async () => undefined,
453
+ closeSession: async () => undefined,
454
+ };
455
+
456
+ const manager = new WatcherManager({
457
+ stateStore,
458
+ openSpec: createPlanningOpenSpec(changeDir, proposalPath),
459
+ archiveDirName: "archives",
460
+ logger: createLogger(),
461
+ notifier: { send: async (_: string, text: string) => { notifierMessages.push(text); } } as any,
462
+ acpClient: fakeAcpClient as any,
463
+ pollIntervalMs: 25,
464
+ });
465
+
466
+ const channelKey = "discord:watch-plan-restart:default:main";
467
+ await stateStore.createProject(channelKey);
468
+ await stateStore.updateProject(channelKey, (current) => ({
469
+ ...current,
470
+ workspacePath,
471
+ repoPath,
472
+ projectName: "demo-app",
473
+ projectTitle: "Demo App",
474
+ changeName,
475
+ changeDir,
476
+ status: "planning",
477
+ phase: "planning_sync",
478
+ planningJournal: {
479
+ dirty: true,
480
+ entryCount: 1,
481
+ lastEntryAt: new Date(Date.now() - 60_000).toISOString(),
482
+ },
483
+ execution: {
484
+ mode: "apply",
485
+ action: "plan",
486
+ state: "armed",
487
+ armedAt: new Date().toISOString(),
488
+ },
489
+ }));
490
+
491
+ await manager.start();
492
+ await manager.wake(channelKey);
493
+ await waitFor(async () => (await stateStore.getActiveProject(channelKey))?.status === "ready", 8_000);
494
+ await manager.stop();
495
+
496
+ const project = await stateStore.getActiveProject(channelKey);
497
+ assert.equal(project?.status, "ready");
498
+ assert.equal(runCount, 2);
499
+ assert.equal(notifierMessages.some((message) => message.includes("Restarting ACP worker")), true);
500
+ assert.equal(
501
+ notifierMessages.some((message) => message.includes("Planning ready.") && message.includes("run `cs-work`")),
502
+ true,
503
+ );
504
+ });