clawspec 1.0.7 → 1.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawspec",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -188,6 +188,14 @@ const plugin = {
188
188
  }, event.content);
189
189
  });
190
190
 
191
+ api.on("before_dispatch", async (event, ctx) => {
192
+ await stateStore.initialize();
193
+ if (!service) {
194
+ return;
195
+ }
196
+ return await service.handleBeforeDispatch(event, ctx);
197
+ });
198
+
191
199
  api.on("before_prompt_build", async (event, ctx) => {
192
200
  await stateStore.initialize();
193
201
  if (!service) {
@@ -125,6 +125,14 @@ type PromptBuildContext = {
125
125
  conversationId?: string;
126
126
  };
127
127
 
128
+ type DispatchContext = {
129
+ sessionKey?: string;
130
+ channel?: string;
131
+ channelId?: string;
132
+ accountId?: string;
133
+ conversationId?: string;
134
+ };
135
+
128
136
  type AgentEndEvent = {
129
137
  messages: unknown[];
130
138
  success: boolean;
@@ -277,6 +285,28 @@ export class ClawSpecService {
277
285
  return await this.buildPlanningDiscussionInjection(boundProject, event.prompt);
278
286
  }
279
287
 
288
+ async handleBeforeDispatch(
289
+ event: { content: string; channel?: string },
290
+ ctx: DispatchContext,
291
+ ): Promise<{ handled: boolean; text?: string } | void> {
292
+ const keyword = extractEmbeddedClawSpecKeyword(event.content);
293
+ if (!keyword || keyword.kind === "plan") {
294
+ return;
295
+ }
296
+
297
+ const result = await this.executeDirectKeyword(keyword, {
298
+ channel: event.channel,
299
+ channelId: ctx.channelId,
300
+ accountId: ctx.accountId,
301
+ conversationId: ctx.conversationId,
302
+ sessionKey: ctx.sessionKey,
303
+ });
304
+ return {
305
+ handled: true,
306
+ text: result.text ?? "",
307
+ };
308
+ }
309
+
280
310
  private async handleKeywordPrompt(
281
311
  keyword: ClawSpecKeywordIntent,
282
312
  event: PromptBuildEvent,
@@ -298,78 +328,89 @@ export class ClawSpecService {
298
328
  }
299
329
  return this.buildPluginReplyInjection(event.prompt, planningSync.text ?? "");
300
330
  }
331
+ case "work":
332
+ case "continue": {
333
+ const result = await this.executeDirectKeyword(keyword, ctx);
334
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
335
+ }
336
+ case "attach": {
337
+ const result = await this.executeDirectKeyword(keyword, ctx);
338
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
339
+ }
340
+ case "detach": {
341
+ const result = await this.executeDirectKeyword(keyword, ctx);
342
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
343
+ }
344
+ case "pause": {
345
+ const result = await this.executeDirectKeyword(keyword, ctx);
346
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
347
+ }
348
+ case "status": {
349
+ const result = await this.executeDirectKeyword(keyword, ctx);
350
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
351
+ }
352
+ case "cancel": {
353
+ const result = await this.executeDirectKeyword(keyword, ctx);
354
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
355
+ }
356
+ }
357
+ }
358
+
359
+ private async executeDirectKeyword(
360
+ keyword: ClawSpecKeywordIntent,
361
+ ctx: DispatchContext,
362
+ ): Promise<PluginCommandResult> {
363
+ const match = await this.resolveControlProjectForPromptContext(ctx);
364
+
365
+ switch (keyword.kind) {
301
366
  case "work":
302
367
  case "continue": {
303
368
  if (!match?.project.repoPath || !match.project.changeName) {
304
- return this.buildPluginReplyInjection(
305
- event.prompt,
306
- "Select a project and create a change first with `/clawspec use <project-name>` and `/clawspec proposal <change-name> [description]`.",
307
- );
369
+ return errorReply("Select a project and create a change first with `/clawspec use <project-name>` and `/clawspec proposal <change-name> [description]`.");
308
370
  }
309
371
  if (["archived", "cancelled"].includes(match.project.status)) {
310
- return this.buildPluginReplyInjection(
311
- event.prompt,
312
- `Change \`${match.project.changeName}\` is no longer active. Create a new proposal before starting implementation again.`,
313
- );
372
+ return errorReply(`Change \`${match.project.changeName}\` is no longer active. Create a new proposal before starting implementation again.`);
314
373
  }
315
374
  if (match.project.status === "planning" || match.project.execution?.action === "plan") {
316
- return this.buildPluginReplyInjection(
317
- event.prompt,
318
- `Planning sync for \`${match.project.changeName}\` is still running. Wait for it to finish before starting implementation.`,
319
- );
375
+ return errorReply(`Planning sync for \`${match.project.changeName}\` is still running. Wait for it to finish before starting implementation.`);
320
376
  }
321
377
  if (keyword.kind === "work" && requiresPlanningSync(match.project)) {
322
- return this.buildPluginReplyInjection(
323
- event.prompt,
324
- buildPlanningRequiredMessage(match.project),
325
- );
378
+ return errorReply(buildPlanningRequiredMessage(match.project));
326
379
  }
327
380
  if (match.project.execution?.state === "running" || match.project.status === "running") {
328
- return this.buildPluginReplyInjection(
329
- event.prompt,
330
- `Background execution for \`${match.project.changeName}\` is already running.`,
331
- );
381
+ return okReply(`Background execution for \`${match.project.changeName}\` is already running.`);
332
382
  }
333
- const result = keyword.kind === "continue"
383
+ return keyword.kind === "continue"
334
384
  ? await this.continueProject(match.channelKey)
335
385
  : await this.queueWorkProject(match.channelKey, "apply");
336
- return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
337
386
  }
338
- case "attach": {
387
+ case "attach":
339
388
  if (!match) {
340
- return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
389
+ return errorReply("No active ClawSpec project is bound to this chat.");
341
390
  }
342
- const result = await this.attachProject(match.channelKey, ctx.sessionKey);
343
- return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
344
- }
345
- case "detach": {
391
+ return await this.attachProject(match.channelKey, ctx.sessionKey);
392
+ case "detach":
346
393
  if (!match) {
347
- return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
394
+ return errorReply("No active ClawSpec project is bound to this chat.");
348
395
  }
349
- const result = await this.detachProject(match.channelKey);
350
- return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
351
- }
352
- case "pause": {
396
+ return await this.detachProject(match.channelKey);
397
+ case "pause":
353
398
  if (!match) {
354
- return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
399
+ return errorReply("No active ClawSpec project is bound to this chat.");
355
400
  }
356
- const result = await this.pauseProject(match.channelKey);
357
- return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
358
- }
359
- case "status": {
401
+ return await this.pauseProject(match.channelKey);
402
+ case "status":
360
403
  if (!match) {
361
- return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
404
+ return errorReply("No active ClawSpec project is bound to this chat.");
362
405
  }
363
- const result = await this.projectStatus(match.channelKey);
364
- return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
365
- }
366
- case "cancel": {
406
+ return await this.projectStatus(match.channelKey);
407
+ case "cancel":
367
408
  if (!match) {
368
- return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
409
+ return errorReply("No active ClawSpec project is bound to this chat.");
369
410
  }
370
- const result = await this.cancelProject(match.channelKey);
371
- return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
372
- }
411
+ return await this.cancelProject(match.channelKey);
412
+ case "plan":
413
+ return errorReply("`cs-plan` must run through the visible planning turn.");
373
414
  }
374
415
  }
375
416
 
@@ -750,13 +791,19 @@ export class ClawSpecService {
750
791
  for (const artifactId of artifactIds) {
751
792
  const result = await this.openSpec.instructionsArtifact(project.repoPath, artifactId, project.changeName);
752
793
  results.push(result);
753
- await writeJsonFile(path.join(repoStatePaths.planningInstructionsRoot, `${artifactId}.json`), {
794
+ const instructionFile = path.join(repoStatePaths.planningInstructionsRoot, `${artifactId}.json`);
795
+ await writeJsonFile(instructionFile, {
754
796
  generatedAt: new Date().toISOString(),
755
797
  command: result.command,
756
798
  cwd: result.cwd,
757
799
  durationMs: result.durationMs,
758
800
  instruction: result.parsed,
759
801
  });
802
+ const relativeInstructionFile = normalizeSlashes(path.relative(project.repoPath, instructionFile) || instructionFile);
803
+ await this.sendChannelUpdate(
804
+ project.channelKey,
805
+ `\`${result.command}\` done. Wrote \`${relativeInstructionFile}\`.`,
806
+ );
760
807
  }
761
808
 
762
809
  return results;
@@ -72,6 +72,7 @@ export async function createServiceHarness(prefix: string): Promise<{
72
72
  memoryStore: ProjectMemoryStore;
73
73
  workspaceStore: WorkspaceStore;
74
74
  watcherManager: FakeWatcherManager;
75
+ sentMessages: Array<{ to: string; text: string }>;
75
76
  workspacePath: string;
76
77
  repoPath: string;
77
78
  changeDir: string;
@@ -174,6 +175,7 @@ export async function createServiceHarness(prefix: string): Promise<{
174
175
  } as Record<string, any>;
175
176
 
176
177
  const watcherManager = createFakeWatcherManager();
178
+ const sentMessages: Array<{ to: string; text: string }> = [];
177
179
  const service = new ClawSpecService({
178
180
  api: {
179
181
  config: {
@@ -189,6 +191,18 @@ export async function createServiceHarness(prefix: string): Promise<{
189
191
  ],
190
192
  },
191
193
  },
194
+ runtime: {
195
+ channel: {
196
+ discord: {
197
+ sendMessageDiscord: async (to: string, text: string) => {
198
+ sentMessages.push({ to, text });
199
+ },
200
+ },
201
+ telegram: {},
202
+ slack: {},
203
+ signal: {},
204
+ },
205
+ },
192
206
  logger: createLogger(),
193
207
  } as any,
194
208
  config: {
@@ -221,6 +235,7 @@ export async function createServiceHarness(prefix: string): Promise<{
221
235
  memoryStore,
222
236
  workspaceStore,
223
237
  watcherManager,
238
+ sentMessages,
224
239
  workspacePath,
225
240
  repoPath,
226
241
  changeDir,
@@ -133,7 +133,7 @@ test("apply reports no new planning notes when the chat context is detached", as
133
133
 
134
134
  test("cs-plan runs visible planning sync and writes a fresh snapshot", async () => {
135
135
  const harness = await createServiceHarness("clawspec-visible-plan-");
136
- const { service, stateStore, repoPath, openSpec } = harness;
136
+ const { service, stateStore, repoPath, openSpec, sentMessages } = harness;
137
137
  const channelKey = "discord:visible-plan:default:main";
138
138
  const promptContext = {
139
139
  trigger: "user",
@@ -171,6 +171,23 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
171
171
  assert.match(injected?.prependContext ?? "", /mandatory final line exactly in this shape/i);
172
172
  assert.equal(runningProject?.status, "planning");
173
173
  assert.equal(runningProject?.phase, "planning_sync");
174
+ assert.deepEqual(
175
+ sentMessages.map((entry) => entry.to),
176
+ [
177
+ "channel:visible-plan",
178
+ "channel:visible-plan",
179
+ "channel:visible-plan",
180
+ "channel:visible-plan",
181
+ ],
182
+ );
183
+ assert.match(sentMessages[0]?.text ?? "", /openspec instructions proposal --change demo-change --json/);
184
+ assert.match(sentMessages[0]?.text ?? "", /planning-instructions\/proposal\.json/);
185
+ assert.match(sentMessages[1]?.text ?? "", /openspec instructions specs --change demo-change --json/);
186
+ assert.match(sentMessages[1]?.text ?? "", /planning-instructions\/specs\.json/);
187
+ assert.match(sentMessages[2]?.text ?? "", /openspec instructions design --change demo-change --json/);
188
+ assert.match(sentMessages[2]?.text ?? "", /planning-instructions\/design\.json/);
189
+ assert.match(sentMessages[3]?.text ?? "", /openspec instructions tasks --change demo-change --json/);
190
+ assert.match(sentMessages[3]?.text ?? "", /planning-instructions\/tasks\.json/);
174
191
 
175
192
  await service.handleAgentEnd(
176
193
  { messages: [], success: true, durationMs: 10 },
@@ -65,6 +65,64 @@ test("work queues background implementation", async () => {
65
65
  assert.deepEqual(watcherManager.wakeCalls, [channelKey]);
66
66
  });
67
67
 
68
+ test("before_dispatch intercepts cs-work and queues background implementation directly", async () => {
69
+ const harness = await createServiceHarness("clawspec-work-dispatch-");
70
+ const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
71
+ const channelKey = "discord:work-dispatch:default:main";
72
+ const tasksPath = path.join(changeDir, "tasks.md");
73
+ await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
74
+
75
+ harness.openSpec.instructionsApply = async (cwd: string, changeName: string) => ({
76
+ command: `openspec instructions apply --change ${changeName} --json`,
77
+ cwd,
78
+ stdout: "{}",
79
+ stderr: "",
80
+ durationMs: 1,
81
+ parsed: {
82
+ changeName,
83
+ changeDir,
84
+ schemaName: "spec-driven",
85
+ contextFiles: { tasks: tasksPath },
86
+ progress: { total: 1, complete: 0, remaining: 1 },
87
+ tasks: [{ id: "1.1", description: "Build the demo endpoint", done: false }],
88
+ state: "ready",
89
+ instruction: "Implement the remaining task.",
90
+ },
91
+ });
92
+
93
+ await seedPlanningProject(stateStore, channelKey, {
94
+ workspacePath,
95
+ repoPath,
96
+ projectName: "demo-app",
97
+ changeName: "queue-work",
98
+ changeDir,
99
+ phase: "tasks",
100
+ status: "ready",
101
+ planningDirty: false,
102
+ });
103
+ await stateStore.updateProject(channelKey, (current) => ({
104
+ ...current,
105
+ boundSessionKey: "agent:main:discord:channel:work-dispatch",
106
+ }));
107
+
108
+ const result = await service.handleBeforeDispatch(
109
+ { content: "cs-work", channel: "discord" },
110
+ {
111
+ channelId: "work-dispatch",
112
+ accountId: "default",
113
+ conversationId: "main",
114
+ sessionKey: "agent:main:discord:channel:work-dispatch",
115
+ },
116
+ );
117
+ const project = await stateStore.getActiveProject(channelKey);
118
+
119
+ assert.equal(result?.handled, true);
120
+ assert.match(result?.text ?? "", /Execution Queued/);
121
+ assert.equal(project?.status, "armed");
122
+ assert.equal(project?.execution?.action, "work");
123
+ assert.deepEqual(watcherManager.wakeCalls, [channelKey]);
124
+ });
125
+
68
126
  test("main chat agent end does not clear a background worker run", async () => {
69
127
  const harness = await createServiceHarness("clawspec-work-session-");
70
128
  const { service, stateStore, repoPath, workspacePath, changeDir } = harness;