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,2971 @@
1
+ import path from "node:path";
2
+ import type {
3
+ OpenClawConfig,
4
+ OpenClawPluginApi,
5
+ PluginCommandContext,
6
+ PluginCommandResult,
7
+ PluginLogger,
8
+ } from "openclaw/plugin-sdk";
9
+ import { ProjectMemoryStore } from "../memory/store.ts";
10
+ import { OpenSpecClient, OpenSpecCommandError } from "../openspec/cli.ts";
11
+ import { parseTasksFile } from "../openspec/tasks.ts";
12
+ import { PlanningJournalStore } from "../planning/journal.ts";
13
+ import { RollbackStore } from "../rollback/store.ts";
14
+ import { ActiveProjectConflictError, ProjectStateStore } from "../state/store.ts";
15
+ import {
16
+ extractEmbeddedClawSpecKeyword,
17
+ parseClawSpecKeyword,
18
+ type ClawSpecKeywordIntent,
19
+ } from "../control/keywords.ts";
20
+ import type {
21
+ ExecutionControlFile,
22
+ ExecutionMode,
23
+ ExecutionResult,
24
+ OpenSpecApplyInstructionsResponse,
25
+ OpenSpecCommandResult,
26
+ OpenSpecStatusResponse,
27
+ ProjectState,
28
+ TaskCountSummary,
29
+ } from "../types.ts";
30
+ import { splitSubcommand, tokenizeArgs } from "../utils/args.ts";
31
+ import { buildChannelKeyFromCommand, buildLegacyChannelKeyFromCommand } from "../utils/channel-key.ts";
32
+ import {
33
+ appendUtf8,
34
+ directoryExists,
35
+ ensureDir,
36
+ listDirectories,
37
+ normalizeSlashes,
38
+ pathExists,
39
+ readJsonFile,
40
+ readUtf8,
41
+ removeIfExists,
42
+ tryReadUtf8,
43
+ writeJsonFile,
44
+ writeUtf8,
45
+ } from "../utils/fs.ts";
46
+ import {
47
+ formatCommandOutputSection,
48
+ formatExecutionSummary,
49
+ heading,
50
+ } from "../utils/markdown.ts";
51
+ import {
52
+ getChangeDir,
53
+ getRepoStatePaths,
54
+ getTasksPath,
55
+ resolveUserPath,
56
+ type RepoStatePaths,
57
+ } from "../utils/paths.ts";
58
+ import { BLOCKING_EXECUTION_MSG, SELECT_PROJECT_FIRST_MSG } from "../utils/messages.ts";
59
+ import {
60
+ buildHelpText,
61
+ buildPlanningBlockedMessage,
62
+ buildPlanningRequiredMessage,
63
+ buildProposalBlockedMessage,
64
+ collectPromptCandidates,
65
+ dedupeProjects,
66
+ deriveRoutingContext,
67
+ errorReply,
68
+ formatProjectTaskCounts,
69
+ hasBlockingExecution,
70
+ isFinishedStatus,
71
+ isMeaningfulExecutionSummary,
72
+ isProjectContextAttached,
73
+ okReply,
74
+ requiresPlanningSync,
75
+ samePath,
76
+ sanitizePlanningMessageText,
77
+ shouldCapturePlanningMessage,
78
+ shouldHandleUserVisiblePrompt,
79
+ shouldInjectPlanningPrompt,
80
+ shouldInjectProjectPrompt,
81
+ } from "./helpers.ts";
82
+ import { slugToTitle } from "../utils/slug.ts";
83
+ import { WorkspaceStore } from "../workspace/store.ts";
84
+ import { isExecutionTriggerText, readExecutionResult } from "../execution/state.ts";
85
+ import { createWorkerSessionKey, matchesExecutionSession } from "../execution/session.ts";
86
+ import {
87
+ buildExecutionPrependContext,
88
+ buildExecutionSystemContext,
89
+ buildProjectPrependContext,
90
+ buildProjectSystemContext,
91
+ buildPlanningPrependContext,
92
+ buildPlanningSystemContext,
93
+ buildPluginReplyPrependContext,
94
+ buildPluginReplySystemContext,
95
+ } from "../worker/prompts.ts";
96
+ import { loadClawSpecSkillBundle } from "../worker/skills.ts";
97
+ import type { WatcherManager } from "../watchers/manager.ts";
98
+ import type { AcpWorkerStatus } from "../acp/client.ts";
99
+
100
+ type PromptBuildEvent = {
101
+ prompt: string;
102
+ messages: unknown[];
103
+ };
104
+
105
+ type PromptBuildContext = {
106
+ sessionKey?: string;
107
+ sessionId?: string;
108
+ workspaceDir?: string;
109
+ messageProvider?: string;
110
+ trigger?: string;
111
+ channel?: string;
112
+ channelId?: string;
113
+ accountId?: string;
114
+ conversationId?: string;
115
+ };
116
+
117
+ type AgentEndEvent = {
118
+ messages: unknown[];
119
+ success: boolean;
120
+ error?: string;
121
+ durationMs?: number;
122
+ };
123
+
124
+ type ClawSpecServiceOptions = {
125
+ api: OpenClawPluginApi;
126
+ config: OpenClawConfig;
127
+ logger: PluginLogger;
128
+ stateStore: ProjectStateStore;
129
+ memoryStore: ProjectMemoryStore;
130
+ openSpec: OpenSpecClient;
131
+ archiveDirName: string;
132
+ defaultWorkspace: string;
133
+ defaultWorkerAgentId: string;
134
+ workspaceStore: WorkspaceStore;
135
+ allowedChannels?: string[];
136
+ maxAutoContinueTurns?: number;
137
+ maxNoProgressTurns?: number;
138
+ workerWaitTimeoutMs?: number;
139
+ subagentLane?: string;
140
+ watcherManager?: WatcherManager;
141
+ };
142
+
143
+ type ProjectCatalogEntry = {
144
+ label: string;
145
+ repoPath: string;
146
+ source: "workspace";
147
+ };
148
+
149
+ export class ClawSpecService {
150
+ readonly api: OpenClawPluginApi;
151
+ readonly config: OpenClawConfig;
152
+ readonly logger: PluginLogger;
153
+ readonly stateStore: ProjectStateStore;
154
+ readonly memoryStore: ProjectMemoryStore;
155
+ readonly openSpec: OpenSpecClient;
156
+ readonly archiveDirName: string;
157
+ readonly defaultWorkspace: string;
158
+ readonly defaultWorkerAgentId: string;
159
+ readonly workspaceStore: WorkspaceStore;
160
+ readonly allowedChannels?: string[];
161
+ readonly watcherManager?: WatcherManager;
162
+ readonly recentOutboundMessages = new Map<string, Array<{ text: string; timestamp: number }>>();
163
+
164
+ constructor(options: ClawSpecServiceOptions) {
165
+ this.api = options.api;
166
+ this.config = options.config;
167
+ this.logger = options.logger;
168
+ this.stateStore = options.stateStore;
169
+ this.memoryStore = options.memoryStore;
170
+ this.openSpec = options.openSpec;
171
+ this.archiveDirName = options.archiveDirName;
172
+ this.defaultWorkspace = options.defaultWorkspace;
173
+ this.defaultWorkerAgentId = options.defaultWorkerAgentId;
174
+ this.workspaceStore = options.workspaceStore;
175
+ this.allowedChannels = options.allowedChannels;
176
+ this.watcherManager = options.watcherManager;
177
+ }
178
+
179
+ async handleProjectCommand(ctx: PluginCommandContext): Promise<PluginCommandResult> {
180
+ if (this.allowedChannels && this.allowedChannels.length > 0 && !this.allowedChannels.includes(ctx.channel)) {
181
+ return errorReply(`ClawSpec is disabled for channel \`${ctx.channel}\`.`);
182
+ }
183
+
184
+ const { subcommand, rest } = splitSubcommand(ctx.args);
185
+ const channelKey = buildChannelKeyFromCommand(ctx);
186
+ const legacyChannelKey = buildLegacyChannelKeyFromCommand(ctx);
187
+
188
+ if (legacyChannelKey !== channelKey) {
189
+ await this.stateStore.moveActiveProjectChannel(legacyChannelKey, channelKey);
190
+ }
191
+
192
+ try {
193
+ switch (subcommand) {
194
+ case "":
195
+ case "help":
196
+ return okReply(buildHelpText());
197
+ case "workspace":
198
+ return await this.workspaceProject(channelKey, rest);
199
+ case "use":
200
+ return await this.useProject(channelKey, rest);
201
+ case "proposal":
202
+ return await this.proposalProject(channelKey, rest);
203
+ case "worker":
204
+ return await this.workerProject(channelKey, rest);
205
+ case "attach":
206
+ return await this.attachProject(channelKey);
207
+ case "deattach":
208
+ case "detach":
209
+ return await this.detachProject(channelKey);
210
+ case "continue":
211
+ return await this.continueProject(channelKey);
212
+ case "pause":
213
+ return await this.pauseProject(channelKey);
214
+ case "status":
215
+ return await this.projectStatus(channelKey);
216
+ case "archive":
217
+ return await this.archiveProject(channelKey);
218
+ case "cancel":
219
+ return await this.cancelProject(channelKey);
220
+ default:
221
+ return errorReply(`Unknown subcommand \`${subcommand}\`.\n\n${buildHelpText()}`);
222
+ }
223
+ } catch (error) {
224
+ this.logger.error(`[clawspec] ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
225
+ return errorReply(error instanceof Error ? error.message : String(error));
226
+ }
227
+ }
228
+
229
+ async handleBeforePromptBuild(
230
+ event: PromptBuildEvent,
231
+ ctx: PromptBuildContext,
232
+ ): Promise<{ prependContext?: string; prependSystemContext?: string } | void> {
233
+ if (!shouldHandleUserVisiblePrompt(ctx.trigger)) {
234
+ this.logger.debug?.(
235
+ `[clawspec] skipping prompt injection for non-user trigger "${ctx.trigger ?? "unknown"}".`,
236
+ );
237
+ return;
238
+ }
239
+
240
+ const keyword = extractEmbeddedClawSpecKeyword(event.prompt);
241
+ if (keyword) {
242
+ this.logger.debug?.(
243
+ `[clawspec] detected control keyword "${keyword.command}" for prompt build (session=${ctx.sessionKey ?? "unknown"}).`,
244
+ );
245
+ const keywordResult = await this.handleKeywordPrompt(keyword, event, ctx);
246
+ if (keywordResult) {
247
+ return keywordResult;
248
+ }
249
+ }
250
+
251
+ const promptProject = await this.resolveProjectForPromptContext(ctx);
252
+ if (!promptProject) {
253
+ return;
254
+ }
255
+
256
+ const boundProject = await this.bindProjectSession(promptProject.channelKey, promptProject.project, ctx.sessionKey);
257
+ if (!isProjectContextAttached(boundProject)) {
258
+ return;
259
+ }
260
+ if (shouldInjectProjectPrompt(boundProject, event.prompt)) {
261
+ return await this.buildProjectDiscussionInjection(boundProject, event.prompt);
262
+ }
263
+ if (!shouldInjectPlanningPrompt(boundProject, event.prompt)) {
264
+ return;
265
+ }
266
+ return await this.buildPlanningDiscussionInjection(boundProject, event.prompt);
267
+ }
268
+
269
+ private async handleKeywordPrompt(
270
+ keyword: ClawSpecKeywordIntent,
271
+ event: PromptBuildEvent,
272
+ ctx: PromptBuildContext,
273
+ ): Promise<{ prependContext?: string; prependSystemContext?: string } | void> {
274
+ const match = await this.resolveControlProjectForPromptContext(ctx);
275
+
276
+ switch (keyword.kind) {
277
+ case "plan": {
278
+ if (!match?.project.repoPath || !match.project.changeName) {
279
+ return this.buildPluginReplyInjection(
280
+ event.prompt,
281
+ "Select a project and create a change first with `/clawspec use <project-name>` and `/clawspec proposal <change-name> [description]`.",
282
+ );
283
+ }
284
+ const planningSync = await this.startVisiblePlanningSync(match.channelKey, match.project, ctx, event.prompt, "apply");
285
+ if ("prependContext" in planningSync || "prependSystemContext" in planningSync) {
286
+ return planningSync;
287
+ }
288
+ return this.buildPluginReplyInjection(event.prompt, planningSync.text ?? "");
289
+ }
290
+ case "work":
291
+ case "continue": {
292
+ if (!match?.project.repoPath || !match.project.changeName) {
293
+ return this.buildPluginReplyInjection(
294
+ event.prompt,
295
+ "Select a project and create a change first with `/clawspec use <project-name>` and `/clawspec proposal <change-name> [description]`.",
296
+ );
297
+ }
298
+ if (["archived", "cancelled"].includes(match.project.status)) {
299
+ return this.buildPluginReplyInjection(
300
+ event.prompt,
301
+ `Change \`${match.project.changeName}\` is no longer active. Create a new proposal before starting implementation again.`,
302
+ );
303
+ }
304
+ if (match.project.status === "planning" || match.project.execution?.action === "plan") {
305
+ return this.buildPluginReplyInjection(
306
+ event.prompt,
307
+ `Planning sync for \`${match.project.changeName}\` is still running. Wait for it to finish before starting implementation.`,
308
+ );
309
+ }
310
+ if (keyword.kind === "work" && requiresPlanningSync(match.project)) {
311
+ return this.buildPluginReplyInjection(
312
+ event.prompt,
313
+ buildPlanningRequiredMessage(match.project),
314
+ );
315
+ }
316
+ if (match.project.execution?.state === "running" || match.project.status === "running") {
317
+ return this.buildPluginReplyInjection(
318
+ event.prompt,
319
+ `Background execution for \`${match.project.changeName}\` is already running.`,
320
+ );
321
+ }
322
+ const result = keyword.kind === "continue"
323
+ ? await this.continueProject(match.channelKey)
324
+ : await this.queueWorkProject(match.channelKey, "apply");
325
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
326
+ }
327
+ case "attach": {
328
+ if (!match) {
329
+ return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
330
+ }
331
+ const result = await this.attachProject(match.channelKey, ctx.sessionKey);
332
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
333
+ }
334
+ case "detach": {
335
+ if (!match) {
336
+ return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
337
+ }
338
+ const result = await this.detachProject(match.channelKey);
339
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
340
+ }
341
+ case "pause": {
342
+ if (!match) {
343
+ return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
344
+ }
345
+ const result = await this.pauseProject(match.channelKey);
346
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
347
+ }
348
+ case "status": {
349
+ if (!match) {
350
+ return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
351
+ }
352
+ const result = await this.projectStatus(match.channelKey);
353
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
354
+ }
355
+ case "cancel": {
356
+ if (!match) {
357
+ return this.buildPluginReplyInjection(event.prompt, "No active ClawSpec project is bound to this chat.");
358
+ }
359
+ const result = await this.cancelProject(match.channelKey);
360
+ return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
361
+ }
362
+ }
363
+ }
364
+
365
+ private async resolveProjectForPromptContext(ctx: PromptBuildContext): Promise<{
366
+ channelKey: string;
367
+ project: ProjectState;
368
+ } | null> {
369
+ return this.resolveProjectForPromptContextInternal(ctx, { allowDetached: false });
370
+ }
371
+
372
+ private async resolveProjectForPromptContextInternal(
373
+ ctx: PromptBuildContext,
374
+ options: { allowDetached: boolean },
375
+ ): Promise<{
376
+ channelKey: string;
377
+ project: ProjectState;
378
+ } | null> {
379
+ if (ctx.sessionKey) {
380
+ const projects = dedupeProjects(await this.stateStore.listActiveProjects());
381
+ const bySession = projects.find((entry) =>
382
+ entry.project.boundSessionKey === ctx.sessionKey
383
+ || entry.project.execution?.sessionKey === ctx.sessionKey
384
+ );
385
+ if (bySession) {
386
+ if (options.allowDetached || isProjectContextAttached(bySession.project)) {
387
+ this.logger.debug?.(
388
+ `[clawspec] prompt context matched by session: session=${ctx.sessionKey} channel=${bySession.channelKey} change=${bySession.project.changeName ?? "none"}.`,
389
+ );
390
+ return bySession;
391
+ }
392
+ this.logger.debug?.(
393
+ `[clawspec] prompt context skipped because project context is detached: session=${ctx.sessionKey} channel=${bySession.channelKey} change=${bySession.project.changeName ?? "none"}.`,
394
+ );
395
+ return null;
396
+ }
397
+ }
398
+
399
+ const routingContext = deriveRoutingContext(ctx);
400
+ if (routingContext.channelId) {
401
+ const match = await this.stateStore.findActiveProjectForMessage({
402
+ channel: routingContext.channel,
403
+ channelId: routingContext.channelId,
404
+ accountId: routingContext.accountId,
405
+ conversationId: routingContext.conversationId,
406
+ });
407
+ if (match) {
408
+ if (options.allowDetached || isProjectContextAttached(match.project)) {
409
+ this.logger.debug?.(
410
+ `[clawspec] prompt context matched by channel: channelId=${routingContext.channelId} account=${routingContext.accountId ?? "default"} conversation=${routingContext.conversationId ?? "main"} mapped=${match.channelKey} change=${match.project.changeName ?? "none"}.`,
411
+ );
412
+ return match;
413
+ }
414
+ this.logger.debug?.(
415
+ `[clawspec] prompt context skipped because project context is detached: channel=${routingContext.channel ?? "unknown"} channelId=${routingContext.channelId} account=${routingContext.accountId ?? "default"} conversation=${routingContext.conversationId ?? "main"} mapped=${match.channelKey} change=${match.project.changeName ?? "none"}.`,
416
+ );
417
+ return null;
418
+ }
419
+ const activeProjects = dedupeProjects(await this.stateStore.listActiveProjects());
420
+ const attachedProjects = activeProjects.filter((entry) => isProjectContextAttached(entry.project));
421
+ if (attachedProjects.length > 0) {
422
+ this.logger.warn(
423
+ `[clawspec] prompt context found no active project: channel=${routingContext.channel ?? "unknown"} channelId=${routingContext.channelId} account=${routingContext.accountId ?? "default"} conversation=${routingContext.conversationId ?? "main"} session=${ctx.sessionKey ?? "unknown"} active=${attachedProjects.map((entry) => entry.channelKey).join(", ")}.`,
424
+ );
425
+ } else {
426
+ this.logger.debug?.(
427
+ `[clawspec] prompt context found no active project: channelId=${routingContext.channelId} account=${routingContext.accountId ?? "default"} conversation=${routingContext.conversationId ?? "main"} session=${ctx.sessionKey ?? "unknown"}.`,
428
+ );
429
+ }
430
+ }
431
+
432
+ return null;
433
+ }
434
+
435
+ private async resolveControlProjectForPromptContext(ctx: PromptBuildContext): Promise<{
436
+ channelKey: string;
437
+ project: ProjectState;
438
+ } | null> {
439
+ const direct = await this.resolveProjectForPromptContextInternal(ctx, { allowDetached: true });
440
+ if (direct) {
441
+ return direct;
442
+ }
443
+
444
+ return null;
445
+ }
446
+
447
+ private async startVisibleExecution(
448
+ project: ProjectState,
449
+ ctx: PromptBuildContext,
450
+ userPrompt: string,
451
+ mode: ExecutionMode,
452
+ ): Promise<{ prependContext?: string; prependSystemContext?: string }> {
453
+ const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
454
+ await this.ensureProjectSupportFiles(project);
455
+
456
+ if (project.status === "planning") {
457
+ return this.buildPluginReplyInjection(
458
+ userPrompt,
459
+ `Planning sync for \`${project.changeName}\` is still in progress. Wait for it to finish, then send \`cs-work\` again.`,
460
+ );
461
+ }
462
+
463
+ if (requiresPlanningSync(project)) {
464
+ await removeIfExists(repoStatePaths.executionControlFile);
465
+ await this.stateStore.updateProject(project.channelKey, (current) => ({
466
+ ...current,
467
+ status: "ready",
468
+ phase: current.phase,
469
+ execution: undefined,
470
+ latestSummary: buildPlanningRequiredMessage(current),
471
+ }));
472
+ return this.buildPluginReplyInjection(userPrompt, buildPlanningRequiredMessage(project));
473
+ }
474
+
475
+ const startedAt = new Date().toISOString();
476
+ const runningProject = await this.stateStore.updateProject(project.channelKey, (current) => ({
477
+ ...current,
478
+ status: "running",
479
+ phase: current.planningJournal?.dirty ? "planning_sync" : "implementing",
480
+ latestSummary: `Visible execution started for ${current.changeName}.`,
481
+ pauseRequested: false,
482
+ cancelRequested: false,
483
+ blockedReason: undefined,
484
+ boundSessionKey: ctx.sessionKey ?? current.boundSessionKey,
485
+ execution: {
486
+ mode,
487
+ action: current.planningJournal?.dirty ? "plan" : "work",
488
+ state: "running",
489
+ armedAt: current.execution?.armedAt ?? startedAt,
490
+ startedAt,
491
+ sessionKey: ctx.sessionKey ?? current.execution?.sessionKey ?? current.boundSessionKey,
492
+ triggerPrompt: userPrompt,
493
+ lastTriggerAt: startedAt,
494
+ },
495
+ }));
496
+
497
+ await writeJsonFile(repoStatePaths.executionControlFile, this.buildExecutionControl(runningProject));
498
+ await removeIfExists(repoStatePaths.executionResultFile);
499
+
500
+ const importedSkills = await loadClawSpecSkillBundle(["apply", "propose"]);
501
+ return {
502
+ prependSystemContext: buildExecutionSystemContext(runningProject.repoPath!, importedSkills),
503
+ prependContext: buildExecutionPrependContext({
504
+ project: runningProject,
505
+ mode,
506
+ userPrompt,
507
+ repoStatePaths,
508
+ }),
509
+ };
510
+ }
511
+
512
+ private async buildPlanningDiscussionInjection(
513
+ project: ProjectState,
514
+ userPrompt: string,
515
+ ): Promise<{ prependContext?: string; prependSystemContext?: string }> {
516
+ const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
517
+ await this.ensureProjectSupportFiles(project);
518
+ const planningContext = {
519
+ paths: [repoStatePaths.stateFile],
520
+ scaffoldOnly: false,
521
+ };
522
+ const importedSkills = await loadClawSpecSkillBundle(["explore"]);
523
+ return {
524
+ prependSystemContext: buildPlanningSystemContext({
525
+ repoPath: project.repoPath!,
526
+ importedSkills,
527
+ mode: "discussion",
528
+ }),
529
+ prependContext: buildPlanningPrependContext({
530
+ project,
531
+ userPrompt,
532
+ repoStatePaths,
533
+ contextPaths: planningContext.paths,
534
+ scaffoldOnly: planningContext.scaffoldOnly,
535
+ mode: "discussion",
536
+ nextActionHint: requiresPlanningSync(project) ? "plan" : "work",
537
+ }),
538
+ };
539
+ }
540
+
541
+ private async buildProjectDiscussionInjection(
542
+ project: ProjectState,
543
+ userPrompt: string,
544
+ ): Promise<{ prependContext?: string; prependSystemContext?: string }> {
545
+ return {
546
+ prependSystemContext: buildProjectSystemContext({
547
+ repoPath: project.repoPath!,
548
+ }),
549
+ prependContext: buildProjectPrependContext({
550
+ project,
551
+ userPrompt,
552
+ }),
553
+ };
554
+ }
555
+
556
+ private async buildPlanningSyncInjection(
557
+ project: ProjectState,
558
+ userPrompt: string,
559
+ ): Promise<{ prependContext?: string; prependSystemContext?: string }> {
560
+ const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
561
+ await this.ensureProjectSupportFiles(project);
562
+ const planningContext = await this.collectPlanningContextPaths(project, repoStatePaths);
563
+ const importedSkills = await loadClawSpecSkillBundle(["explore", "propose"]);
564
+ return {
565
+ prependSystemContext: buildPlanningSystemContext({
566
+ repoPath: project.repoPath!,
567
+ importedSkills,
568
+ mode: "sync",
569
+ }),
570
+ prependContext: buildPlanningPrependContext({
571
+ project,
572
+ userPrompt,
573
+ repoStatePaths,
574
+ contextPaths: planningContext.paths,
575
+ scaffoldOnly: planningContext.scaffoldOnly,
576
+ mode: "sync",
577
+ }),
578
+ };
579
+ }
580
+
581
+ private async preparePlanningSync(channelKey: string): Promise<
582
+ | { result: PluginCommandResult }
583
+ | { project: ProjectState; outputs: OpenSpecCommandResult[]; repoStatePaths: RepoStatePaths }
584
+ > {
585
+ const project = await this.requireActiveProject(channelKey);
586
+ if (!project.repoPath || !project.projectName || !project.changeName) {
587
+ return {
588
+ result: errorReply("Select a project and create a change first with `/clawspec use` and `/clawspec proposal`."),
589
+ };
590
+ }
591
+ if (["archived", "cancelled"].includes(project.status)) {
592
+ return {
593
+ result: errorReply(`Change \`${project.changeName}\` is no longer active. Create a new proposal before running planning sync again.`),
594
+ };
595
+ }
596
+ if (project.status === "planning" || project.execution?.action === "plan") {
597
+ return {
598
+ result: errorReply(`Planning sync for \`${project.changeName}\` is already running.`),
599
+ };
600
+ }
601
+ if (hasBlockingExecution(project)) {
602
+ return {
603
+ result: errorReply(BLOCKING_EXECUTION_MSG),
604
+ };
605
+ }
606
+
607
+ const outputs: OpenSpecCommandResult[] = [];
608
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
609
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
610
+
611
+ try {
612
+ await this.ensureProjectSupportFiles(project);
613
+ const hasUnsyncedChanges = await journalStore.hasUnsyncedChanges(
614
+ project.changeName,
615
+ repoStatePaths.planningJournalSnapshotFile,
616
+ project.planningJournal?.lastSyncedAt,
617
+ );
618
+ if (!hasUnsyncedChanges) {
619
+ const isDetached = !isProjectContextAttached(project);
620
+ if (isDetached) {
621
+ const snapshot = await journalStore.readSnapshot(repoStatePaths.planningJournalSnapshotFile);
622
+ const digest = await journalStore.digest(project.changeName);
623
+ const latestSummary = `No new planning notes were captured for ${project.changeName} because chat context is detached.`;
624
+ await this.stateStore.updateProject(channelKey, (current) => ({
625
+ ...current,
626
+ planningJournal: {
627
+ dirty: false,
628
+ entryCount: digest.entryCount,
629
+ lastEntryAt: digest.lastEntryAt,
630
+ lastSyncedAt: snapshot?.syncedAt ?? current.planningJournal?.lastSyncedAt,
631
+ },
632
+ latestSummary,
633
+ }));
634
+ return {
635
+ result: okReply(
636
+ [
637
+ heading("No New Planning Notes"),
638
+ "",
639
+ `Change: \`${project.changeName}\``,
640
+ "This chat is currently detached from ClawSpec context, so ordinary requirement messages are not being written to the planning journal.",
641
+ "Next step: run `cs-attach` or `/clawspec attach`, resend the requirement, then run `cs-plan` again.",
642
+ ].join("\n"),
643
+ ),
644
+ };
645
+ }
646
+
647
+ await this.stateStore.updateProject(channelKey, (current) => ({
648
+ ...current,
649
+ latestSummary: `Manual planning review requested for ${project.changeName}.`,
650
+ }));
651
+ }
652
+ const statusResult = await this.openSpec.status(project.repoPath, project.changeName);
653
+ outputs.push(statusResult);
654
+ } catch (error) {
655
+ if (error instanceof OpenSpecCommandError) {
656
+ return {
657
+ result: errorReply(
658
+ [
659
+ heading("Planning Preparation Failed"),
660
+ "",
661
+ `Change: \`${project.changeName}\``,
662
+ "",
663
+ formatCommandOutputSection([error.result]),
664
+ ].join("\n"),
665
+ ),
666
+ };
667
+ }
668
+ throw error;
669
+ }
670
+
671
+ await removeIfExists(repoStatePaths.executionControlFile);
672
+ await removeIfExists(repoStatePaths.executionResultFile);
673
+ await removeIfExists(repoStatePaths.workerProgressFile);
674
+ return {
675
+ project,
676
+ outputs,
677
+ repoStatePaths,
678
+ };
679
+ }
680
+
681
+ private async startVisiblePlanningSync(
682
+ channelKey: string,
683
+ project: ProjectState,
684
+ ctx: PromptBuildContext,
685
+ userPrompt: string,
686
+ mode: ExecutionMode,
687
+ ): Promise<{ prependContext?: string; prependSystemContext?: string } | PluginCommandResult> {
688
+ void project;
689
+ void mode;
690
+
691
+ const prepared = await this.preparePlanningSync(channelKey);
692
+ if ("result" in prepared) {
693
+ return prepared.result;
694
+ }
695
+
696
+ const startedAt = new Date().toISOString();
697
+ const runningProject = await this.stateStore.updateProject(channelKey, (current) => ({
698
+ ...current,
699
+ status: "planning",
700
+ phase: "planning_sync",
701
+ pauseRequested: false,
702
+ cancelRequested: false,
703
+ blockedReason: undefined,
704
+ latestSummary: `Planning sync started for ${current.changeName} in the visible chat.`,
705
+ boundSessionKey: ctx.sessionKey ?? current.boundSessionKey,
706
+ execution: undefined,
707
+ lastExecutionAt: startedAt,
708
+ }));
709
+
710
+ return await this.buildPlanningSyncInjection(runningProject, userPrompt);
711
+ }
712
+
713
+ private async collectPlanningContextPaths(
714
+ project: ProjectState,
715
+ repoStatePaths: RepoStatePaths,
716
+ ): Promise<{ paths: string[]; scaffoldOnly: boolean }> {
717
+ const paths = [
718
+ repoStatePaths.stateFile,
719
+ repoStatePaths.planningJournalFile,
720
+ ];
721
+
722
+ if (!project.changeDir) {
723
+ return {
724
+ paths,
725
+ scaffoldOnly: true,
726
+ };
727
+ }
728
+
729
+ const scaffoldPath = path.join(project.changeDir, ".openspec.yaml");
730
+ if (await pathExists(scaffoldPath)) {
731
+ paths.push(scaffoldPath);
732
+ }
733
+
734
+ const proposalPath = path.join(project.changeDir, "proposal.md");
735
+ const designPath = path.join(project.changeDir, "design.md");
736
+ const specsRoot = path.join(project.changeDir, "specs");
737
+ const tasksPath = project.repoPath && project.changeName
738
+ ? getTasksPath(project.repoPath, project.changeName)
739
+ : undefined;
740
+
741
+ let hasPlanningArtifacts = false;
742
+
743
+ if (await pathExists(proposalPath)) {
744
+ paths.push(proposalPath);
745
+ hasPlanningArtifacts = true;
746
+ }
747
+ if (await pathExists(designPath)) {
748
+ paths.push(designPath);
749
+ hasPlanningArtifacts = true;
750
+ }
751
+ if (await directoryExists(specsRoot)) {
752
+ paths.push(path.join(project.changeDir, "specs", "**", "*.md"));
753
+ hasPlanningArtifacts = true;
754
+ }
755
+ if (tasksPath && await pathExists(tasksPath)) {
756
+ paths.push(tasksPath);
757
+ hasPlanningArtifacts = true;
758
+ }
759
+
760
+ return {
761
+ paths,
762
+ scaffoldOnly: !hasPlanningArtifacts,
763
+ };
764
+ }
765
+
766
+ private buildPluginReplyInjection(
767
+ userPrompt: string,
768
+ resultText: string,
769
+ followUp?: string,
770
+ ): { prependContext?: string; prependSystemContext?: string } {
771
+ return {
772
+ prependSystemContext: buildPluginReplySystemContext(),
773
+ prependContext: buildPluginReplyPrependContext({
774
+ userPrompt,
775
+ resultText,
776
+ followUp,
777
+ }),
778
+ };
779
+ }
780
+
781
+ async handleAgentEnd(event: AgentEndEvent, ctx: PromptBuildContext): Promise<void> {
782
+ const runningProject = await this.findRunningProjectBySessionKey(ctx.sessionKey);
783
+ if (runningProject?.repoPath && runningProject.changeName) {
784
+ const project = runningProject;
785
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
786
+ const executionResult = await readExecutionResult(repoStatePaths.executionResultFile);
787
+ const taskCounts = await this.loadTaskCounts(project);
788
+ const fallbackSummary = executionResult
789
+ ? undefined
790
+ : await this.readMeaningfulExecutionSummary(repoStatePaths);
791
+
792
+ if (executionResult) {
793
+ await this.updateSupportFilesFromExecutionResult(project, executionResult);
794
+ } else if (!fallbackSummary) {
795
+ await this.writeLatestSummary(
796
+ repoStatePaths,
797
+ event.success
798
+ ? "Execution turn ended without writing execution-result.json."
799
+ : `Execution turn failed before writing execution-result.json: ${event.error ?? "unknown error"}`,
800
+ );
801
+ }
802
+
803
+ await removeIfExists(repoStatePaths.executionControlFile);
804
+
805
+ if (executionResult?.status === "cancelled" || (project.cancelRequested && !executionResult)) {
806
+ await this.finalizeCancellation(project, executionResult);
807
+ return;
808
+ }
809
+
810
+ const nextTaskCounts = executionResult?.taskCounts ?? taskCounts;
811
+ const nextStatus = this.resolvePostRunStatus(project, executionResult, nextTaskCounts, event);
812
+ const nextPhase = nextStatus === "done"
813
+ ? "validating"
814
+ : nextStatus === "blocked"
815
+ ? "implementing"
816
+ : nextStatus === "paused"
817
+ ? "implementing"
818
+ : "ready";
819
+ const latestSummary = executionResult?.summary
820
+ ?? fallbackSummary
821
+ ?? (event.success ? "Visible execution ended without a structured result." : `Execution failed: ${event.error ?? "unknown error"}`);
822
+ const blockedReason = executionResult?.status === "blocked"
823
+ ? executionResult.blocker ?? executionResult.summary
824
+ : nextStatus === "blocked"
825
+ ? (fallbackSummary ?? (event.success ? "Visible execution ended without a structured result." : `Execution failed: ${event.error ?? "unknown error"}`))
826
+ : undefined;
827
+
828
+ await this.stateStore.updateProject(project.channelKey, (current) => ({
829
+ ...current,
830
+ status: nextStatus,
831
+ phase: nextPhase,
832
+ pauseRequested: false,
833
+ cancelRequested: false,
834
+ blockedReason,
835
+ taskCounts: nextTaskCounts,
836
+ latestSummary,
837
+ execution: undefined,
838
+ lastExecution: executionResult ?? current.lastExecution,
839
+ lastExecutionAt: executionResult?.timestamp ?? new Date().toISOString(),
840
+ }));
841
+ return;
842
+ }
843
+
844
+ const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey);
845
+ if (planningProject) {
846
+ await this.finalizePlanningTurn(planningProject, event);
847
+ return;
848
+ }
849
+
850
+ const discussionProject = await this.findDiscussionProjectBySessionKey(ctx.sessionKey);
851
+ if (discussionProject) {
852
+ await this.captureAssistantPlanningMessage(discussionProject, event);
853
+ }
854
+ }
855
+
856
+ async recordPlanningMessage(channelKey: string, text: string): Promise<void> {
857
+ const project = await this.stateStore.getActiveProject(channelKey);
858
+ if (!project) {
859
+ return;
860
+ }
861
+ await this.captureIncomingMessage(channelKey, project, text);
862
+ }
863
+
864
+ async recordPlanningMessageFromContext(params: {
865
+ channel?: string;
866
+ channelId?: string;
867
+ accountId?: string;
868
+ conversationId?: string;
869
+ sessionKey?: string;
870
+ from?: string;
871
+ metadata?: Record<string, unknown>;
872
+ }, text: string): Promise<void> {
873
+ const routingContext = deriveRoutingContext(params);
874
+ if (!routingContext.channelId) {
875
+ return;
876
+ }
877
+ if (this.shouldIgnoreInboundPlanningMessage(params, text)) {
878
+ return;
879
+ }
880
+ const match = await this.stateStore.findActiveProjectForMessage({
881
+ channel: routingContext.channel,
882
+ channelId: routingContext.channelId,
883
+ accountId: routingContext.accountId,
884
+ conversationId: routingContext.conversationId,
885
+ });
886
+ if (!match) {
887
+ const activeProjects = dedupeProjects(await this.stateStore.listActiveProjects());
888
+ if (activeProjects.length > 0) {
889
+ this.logger.warn(
890
+ `[clawspec] planning message ignored because no active project matched: channel=${routingContext.channel ?? "unknown"} channelId=${routingContext.channelId} account=${routingContext.accountId ?? "default"} conversation=${routingContext.conversationId ?? "main"} session=${params.sessionKey ?? "unknown"} active=${activeProjects.map((entry) => entry.channelKey).join(", ")}.`,
891
+ );
892
+ } else {
893
+ this.logger.debug?.(
894
+ `[clawspec] planning message ignored because no active project matched: channelId=${routingContext.channelId} account=${routingContext.accountId ?? "default"} conversation=${routingContext.conversationId ?? "main"}.`,
895
+ );
896
+ }
897
+ return;
898
+ }
899
+ if (!isProjectContextAttached(match.project)) {
900
+ this.logger.debug?.(
901
+ `[clawspec] planning message ignored because context is detached for channel=${match.channelKey} change=${match.project.changeName ?? "none"}.`,
902
+ );
903
+ return;
904
+ }
905
+ this.logger.debug?.(
906
+ `[clawspec] planning message captured for channel=${match.channelKey} change=${match.project.changeName ?? "none"}.`,
907
+ );
908
+ await this.captureIncomingMessage(match.channelKey, match.project, text);
909
+ }
910
+
911
+ recordOutboundMessageFromContext(params: {
912
+ channelId?: string;
913
+ accountId?: string;
914
+ conversationId?: string;
915
+ }, text: string): void {
916
+ const normalized = sanitizePlanningMessageText(text).trim();
917
+ if (!params.channelId || !normalized) {
918
+ return;
919
+ }
920
+ const scopeKey = this.buildMessageScopeKey(params);
921
+ const now = Date.now();
922
+ const entries = (this.recentOutboundMessages.get(scopeKey) ?? [])
923
+ .filter((entry) => now - entry.timestamp < 120_000);
924
+ entries.push({ text: normalized, timestamp: now });
925
+ this.recentOutboundMessages.set(scopeKey, entries.slice(-20));
926
+ }
927
+
928
+ async startProject(channelKey: string): Promise<PluginCommandResult> {
929
+ try {
930
+ const workspacePath = await this.workspaceStore.getCurrentWorkspace(channelKey);
931
+ const project = await this.ensureSessionProject(channelKey, workspacePath);
932
+ return okReply(
933
+ [
934
+ heading("Project Started"),
935
+ "",
936
+ `Project id: \`${project.projectId}\``,
937
+ `Current workspace: \`${project.workspacePath ?? workspacePath}\``,
938
+ "Next step: `/clawspec use` to browse projects or `/clawspec use <project-name>` to select one.",
939
+ ].join("\n"),
940
+ );
941
+ } catch (error) {
942
+ if (error instanceof ActiveProjectConflictError) {
943
+ return okReply(await this.renderStatus(error.project, "Project session already exists in this channel."));
944
+ }
945
+ throw error;
946
+ }
947
+ }
948
+
949
+ async workspaceProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
950
+ const currentWorkspace = await this.workspaceStore.getCurrentWorkspace(channelKey);
951
+ const project = await this.ensureSessionProject(channelKey, currentWorkspace);
952
+ const requested = rawArgs.trim();
953
+
954
+ if (!requested) {
955
+ return okReply(await this.buildWorkspaceText(project));
956
+ }
957
+ if (hasBlockingExecution(project)) {
958
+ return errorReply(BLOCKING_EXECUTION_MSG);
959
+ }
960
+
961
+ const nextWorkspace = resolveUserPath(requested, project.workspacePath ?? this.defaultWorkspace);
962
+ if (project.changeName && !isFinishedStatus(project.status) && project.repoPath) {
963
+ return errorReply(
964
+ `Current project \`${project.projectName ?? path.basename(project.repoPath)}\` still has an unfinished change \`${project.changeName}\`. Use \`/clawspec continue\`, \`/clawspec pause\`, or \`/clawspec cancel\` first.`,
965
+ );
966
+ }
967
+
968
+ await this.workspaceStore.useWorkspace(nextWorkspace, channelKey);
969
+ const updated = await this.stateStore.updateProject(channelKey, (current) => ({
970
+ ...current,
971
+ contextMode: "attached",
972
+ workspacePath: nextWorkspace,
973
+ repoPath: undefined,
974
+ projectName: undefined,
975
+ projectTitle: undefined,
976
+ description: undefined,
977
+ changeName: undefined,
978
+ changeDir: undefined,
979
+ openspecRoot: undefined,
980
+ currentTask: undefined,
981
+ taskCounts: undefined,
982
+ latestSummary: `Switched workspace to ${nextWorkspace}.`,
983
+ pauseRequested: false,
984
+ cancelRequested: false,
985
+ blockedReason: undefined,
986
+ execution: undefined,
987
+ boundSessionKey: undefined,
988
+ planningJournal: undefined,
989
+ rollback: undefined,
990
+ status: "idle",
991
+ phase: "init",
992
+ }));
993
+
994
+ return okReply(await this.buildWorkspaceText(updated, "Workspace switched."));
995
+ }
996
+
997
+ async useProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
998
+ const workspacePath = await this.workspaceStore.getCurrentWorkspace(channelKey);
999
+ const project = await this.ensureSessionProject(channelKey, workspacePath);
1000
+ const input = rawArgs.trim();
1001
+
1002
+ if (!input) {
1003
+ return okReply(await this.buildWorkspaceText(project));
1004
+ }
1005
+ if (hasBlockingExecution(project)) {
1006
+ return errorReply(BLOCKING_EXECUTION_MSG);
1007
+ }
1008
+ if (project.changeName && !isFinishedStatus(project.status) && project.repoPath) {
1009
+ return errorReply(
1010
+ `Project \`${project.projectName ?? path.basename(project.repoPath)}\` still has an unfinished change \`${project.changeName}\`. Use \`/clawspec continue\` or \`/clawspec cancel\` first.`,
1011
+ );
1012
+ }
1013
+
1014
+ const repoPath = this.resolveWorkspaceProjectPath(project.workspacePath ?? workspacePath, input);
1015
+ const projectName = normalizeSlashes(path.relative(project.workspacePath ?? workspacePath, repoPath) || path.basename(repoPath));
1016
+ const projectExisted = await directoryExists(repoPath);
1017
+ if (!projectExisted) {
1018
+ await ensureDir(repoPath);
1019
+ }
1020
+
1021
+ const outputs: OpenSpecCommandResult[] = [];
1022
+ if (!(await pathExists(path.join(repoPath, "openspec", "config.yaml")))) {
1023
+ try {
1024
+ const initResult = await this.openSpec.init(repoPath);
1025
+ outputs.push(initResult);
1026
+ } catch (error) {
1027
+ if (error instanceof OpenSpecCommandError) {
1028
+ return errorReply(
1029
+ [
1030
+ heading("Project Use Failed"),
1031
+ "",
1032
+ `Project: \`${projectName}\``,
1033
+ `Workspace: \`${project.workspacePath ?? workspacePath}\``,
1034
+ "",
1035
+ formatCommandOutputSection([error.result]),
1036
+ ].join("\n"),
1037
+ );
1038
+ }
1039
+ throw error;
1040
+ }
1041
+ }
1042
+
1043
+ const repoStatePath = getRepoStatePaths(repoPath, this.archiveDirName).stateFile;
1044
+ const repoState = await readJsonFile<ProjectState | null>(repoStatePath, null);
1045
+ const resumedRepoState = Boolean(
1046
+ repoState
1047
+ && samePath(repoState.repoPath, repoPath)
1048
+ && repoState.changeName
1049
+ && !isFinishedStatus(repoState.status),
1050
+ );
1051
+
1052
+ const updated = await this.stateStore.updateProject(channelKey, (current) => {
1053
+ const base = resumedRepoState && repoState
1054
+ ? {
1055
+ ...repoState,
1056
+ channelKey: current.channelKey,
1057
+ storagePath: current.storagePath,
1058
+ }
1059
+ : current;
1060
+ const sameRepo = samePath(base.repoPath, repoPath);
1061
+ return {
1062
+ ...base,
1063
+ contextMode: "attached",
1064
+ workspacePath: project.workspacePath ?? workspacePath,
1065
+ workerAgentId: base.workerAgentId ?? current.workerAgentId ?? this.defaultWorkerAgentId,
1066
+ repoPath,
1067
+ projectName,
1068
+ projectTitle: sameRepo ? base.projectTitle : projectName,
1069
+ openspecRoot: path.join(repoPath, "openspec"),
1070
+ changeName: sameRepo ? base.changeName : undefined,
1071
+ changeDir: sameRepo && base.changeName ? getChangeDir(repoPath, base.changeName) : undefined,
1072
+ description: sameRepo ? base.description : undefined,
1073
+ currentTask: sameRepo ? base.currentTask : undefined,
1074
+ taskCounts: sameRepo ? base.taskCounts : undefined,
1075
+ pauseRequested: false,
1076
+ cancelRequested: false,
1077
+ blockedReason: undefined,
1078
+ execution: sameRepo ? base.execution : undefined,
1079
+ planningJournal: sameRepo ? base.planningJournal : undefined,
1080
+ rollback: sameRepo ? base.rollback : undefined,
1081
+ latestSummary: resumedRepoState
1082
+ ? base.latestSummary ?? `Resumed active change ${base.changeName}.`
1083
+ : projectExisted
1084
+ ? `Using project ${projectName}.`
1085
+ : `Created project folder ${projectName}.`,
1086
+ status: sameRepo ? base.status : "idle",
1087
+ phase: sameRepo ? base.phase : "init",
1088
+ };
1089
+ });
1090
+
1091
+ await this.ensureProjectSupportFiles(updated);
1092
+
1093
+ return okReply(
1094
+ [
1095
+ heading("Project Selected"),
1096
+ "",
1097
+ `Workspace: \`${updated.workspacePath ?? workspacePath}\``,
1098
+ `Project: \`${projectName}\``,
1099
+ `Repo path: \`${repoPath}\``,
1100
+ resumedRepoState && updated.changeName
1101
+ ? `Action: resumed active change \`${updated.changeName}\` for this project.`
1102
+ : projectExisted
1103
+ ? "Action: reused existing project directory."
1104
+ : "Action: created new project directory.",
1105
+ outputs.length > 0 ? "OpenSpec init: completed." : "OpenSpec init: already present.",
1106
+ outputs.length > 0 ? "" : "",
1107
+ formatCommandOutputSection(outputs),
1108
+ outputs.length > 0 ? "" : "",
1109
+ resumedRepoState && updated.changeName
1110
+ ? `Next step: ${requiresPlanningSync(updated) ? "`cs-plan`" : "`cs-work`"}`
1111
+ : "Next step: `/clawspec proposal <change-name> [description]`",
1112
+ ].filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n"),
1113
+ );
1114
+ }
1115
+
1116
+ async proposalProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
1117
+ const workspacePath = await this.workspaceStore.getCurrentWorkspace(channelKey);
1118
+ const project = await this.ensureSessionProject(channelKey, workspacePath);
1119
+ if (!project.repoPath || !project.projectName) {
1120
+ return errorReply(SELECT_PROJECT_FIRST_MSG);
1121
+ }
1122
+ if (hasBlockingExecution(project)) {
1123
+ return errorReply(BLOCKING_EXECUTION_MSG);
1124
+ }
1125
+ if (project.changeName && !isFinishedStatus(project.status)) {
1126
+ return errorReply(buildProposalBlockedMessage(project, project.projectName));
1127
+ }
1128
+ const repoActive = await this.findUnfinishedProjectForRepo(project.repoPath, project.projectId);
1129
+ if (repoActive?.project.changeName) {
1130
+ return errorReply(buildProposalBlockedMessage(repoActive.project, project.projectName));
1131
+ }
1132
+
1133
+ const tokens = tokenizeArgs(rawArgs);
1134
+ const changeName = tokens[0]?.trim();
1135
+ const description = tokens.slice(1).join(" ").trim();
1136
+ if (!changeName) {
1137
+ return errorReply("Usage: `/clawspec proposal <change-name> [description]`\n`change-name` must be kebab-case and cannot contain spaces.");
1138
+ }
1139
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(changeName)) {
1140
+ return errorReply(
1141
+ "`change-name` must use kebab-case and cannot contain spaces, for example `add-project-workspace`.\nIf you want to include a description, put it after the change name.",
1142
+ );
1143
+ }
1144
+
1145
+ const changeDir = getChangeDir(project.repoPath, changeName);
1146
+ if (await pathExists(changeDir)) {
1147
+ return errorReply(
1148
+ `OpenSpec change \`${changeName}\` already exists in project \`${project.projectName}\`. Use \`/clawspec continue\` if this is the active change, otherwise choose a new change name.`,
1149
+ );
1150
+ }
1151
+
1152
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
1153
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
1154
+ const rollbackStore = new RollbackStore(project.repoPath, this.archiveDirName, changeName);
1155
+ const outputs: OpenSpecCommandResult[] = [];
1156
+
1157
+ try {
1158
+ await this.ensureProjectSupportFiles(project);
1159
+ await this.resetRunSupportFiles(repoStatePaths, `Change ${changeName} is ready for planning discussion.`);
1160
+ const manifest = await rollbackStore.initializeBaseline();
1161
+ await journalStore.clear();
1162
+
1163
+ const newChangeResult = await this.openSpec.newChange(project.repoPath, changeName, description || undefined);
1164
+ outputs.push(newChangeResult);
1165
+ await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, changeName);
1166
+
1167
+ try {
1168
+ const statusResult = await this.openSpec.status(project.repoPath, changeName);
1169
+ outputs.push(statusResult);
1170
+ } catch (error) {
1171
+ if (error instanceof OpenSpecCommandError) {
1172
+ outputs.push(error.result);
1173
+ } else {
1174
+ throw error;
1175
+ }
1176
+ }
1177
+
1178
+ await this.stateStore.updateProject(channelKey, (current) => ({
1179
+ ...current,
1180
+ contextMode: "attached",
1181
+ projectTitle: description ? description : slugToTitle(changeName),
1182
+ description: description || undefined,
1183
+ changeName,
1184
+ changeDir,
1185
+ openspecRoot: path.join(project.repoPath!, "openspec"),
1186
+ currentTask: undefined,
1187
+ taskCounts: undefined,
1188
+ pauseRequested: false,
1189
+ cancelRequested: false,
1190
+ blockedReason: undefined,
1191
+ latestSummary: `Proposal scaffold is ready for ${changeName}.`,
1192
+ execution: undefined,
1193
+ lastExecution: undefined,
1194
+ boundSessionKey: current.boundSessionKey ?? project.boundSessionKey,
1195
+ planningJournal: {
1196
+ dirty: false,
1197
+ entryCount: 0,
1198
+ lastSyncedAt: new Date().toISOString(),
1199
+ },
1200
+ rollback: {
1201
+ baselineRoot: manifest.baselineRoot,
1202
+ manifestPath: rollbackStore.manifestPath,
1203
+ snapshotReady: true,
1204
+ touchedFileCount: 0,
1205
+ lastUpdatedAt: manifest.updatedAt,
1206
+ },
1207
+ status: "ready",
1208
+ phase: "proposal",
1209
+ }));
1210
+
1211
+ return okReply(
1212
+ [
1213
+ heading("Proposal Ready"),
1214
+ "",
1215
+ `Project: \`${project.projectName}\``,
1216
+ `Change: \`${changeName}\``,
1217
+ `Repo path: \`${project.repoPath}\``,
1218
+ "",
1219
+ "OpenSpec scaffold created. Continue discussing the requirement in this chat.",
1220
+ "When the requirement is clear enough, say `cs-plan` to refresh proposal/design/tasks in this chat.",
1221
+ "`cs-work` becomes available only after planning sync finishes successfully.",
1222
+ "When planning is ready, use `cs-work` to start implementation. Use `/clawspec continue` later if you pause or get blocked.",
1223
+ "",
1224
+ formatCommandOutputSection(outputs),
1225
+ ].filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n"),
1226
+ );
1227
+ } catch (error) {
1228
+ if (error instanceof OpenSpecCommandError) {
1229
+ return errorReply(
1230
+ [
1231
+ heading("Proposal Failed"),
1232
+ "",
1233
+ `Change: \`${changeName}\``,
1234
+ "",
1235
+ formatCommandOutputSection([error.result]),
1236
+ ].join("\n"),
1237
+ );
1238
+ }
1239
+
1240
+ await rollbackStore.clear().catch(() => undefined);
1241
+ await journalStore.clear().catch(() => undefined);
1242
+ throw error;
1243
+ }
1244
+ }
1245
+
1246
+ async detachProject(channelKey: string): Promise<PluginCommandResult> {
1247
+ const project = await this.requireActiveProject(channelKey);
1248
+ if (!project.repoPath || !project.projectName) {
1249
+ return errorReply(SELECT_PROJECT_FIRST_MSG);
1250
+ }
1251
+ if (!isProjectContextAttached(project)) {
1252
+ return okReply(
1253
+ [
1254
+ heading("Context Detached"),
1255
+ "",
1256
+ `Project: \`${project.projectName}\``,
1257
+ `Change: ${project.changeName ? `\`${project.changeName}\`` : "_none_"}`,
1258
+ "Normal chat is already detached from ClawSpec context in this channel.",
1259
+ "Use `cs-attach` or `/clawspec attach` when you want ordinary chat to re-enter project mode.",
1260
+ ].join("\n"),
1261
+ );
1262
+ }
1263
+
1264
+ const updated = await this.stateStore.updateProject(channelKey, (current) => ({
1265
+ ...current,
1266
+ contextMode: "detached",
1267
+ }));
1268
+
1269
+ return okReply(
1270
+ [
1271
+ heading("Context Detached"),
1272
+ "",
1273
+ `Project: \`${updated.projectName ?? project.projectName}\``,
1274
+ `Change: ${updated.changeName ? `\`${updated.changeName}\`` : "_none_"}`,
1275
+ "Normal chat is now detached from ClawSpec context in this channel.",
1276
+ "Background implementation can keep running, and watcher updates will still appear here.",
1277
+ "Use `cs-attach` or `/clawspec attach` to reattach this chat to the active project context.",
1278
+ ].join("\n"),
1279
+ );
1280
+ }
1281
+
1282
+ async attachProject(channelKey: string, sessionKey?: string): Promise<PluginCommandResult> {
1283
+ const project = await this.requireActiveProject(channelKey);
1284
+ if (!project.repoPath || !project.projectName) {
1285
+ return errorReply(SELECT_PROJECT_FIRST_MSG);
1286
+ }
1287
+ if (isProjectContextAttached(project)) {
1288
+ return okReply(
1289
+ [
1290
+ heading("Context Attached"),
1291
+ "",
1292
+ `Project: \`${project.projectName}\``,
1293
+ `Change: ${project.changeName ? `\`${project.changeName}\`` : "_none_"}`,
1294
+ "This chat is already attached to the active ClawSpec project context.",
1295
+ ].join("\n"),
1296
+ );
1297
+ }
1298
+
1299
+ const updated = await this.stateStore.updateProject(channelKey, (current) => ({
1300
+ ...current,
1301
+ contextMode: "attached",
1302
+ boundSessionKey: sessionKey ?? current.boundSessionKey,
1303
+ }));
1304
+
1305
+ const nextStep = updated.changeName
1306
+ ? requiresPlanningSync(updated)
1307
+ ? "Next: keep describing requirements or run `cs-plan`."
1308
+ : "Next: continue the project discussion here or run `cs-work`."
1309
+ : "Next: run `/clawspec proposal <change-name> [description]` when you want to start a structured change.";
1310
+
1311
+ return okReply(
1312
+ [
1313
+ heading("Context Attached"),
1314
+ "",
1315
+ `Project: \`${updated.projectName ?? project.projectName}\``,
1316
+ `Change: ${updated.changeName ? `\`${updated.changeName}\`` : "_none_"}`,
1317
+ "Ordinary chat in this channel is attached to the active ClawSpec context again.",
1318
+ nextStep,
1319
+ ].join("\n"),
1320
+ );
1321
+ }
1322
+
1323
+ async workerProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
1324
+ const workspacePath = await this.workspaceStore.getCurrentWorkspace(channelKey);
1325
+ const project = await this.ensureSessionProject(channelKey, workspacePath);
1326
+ const requestedAgent = rawArgs.trim();
1327
+ const availableAgents = this.listAvailableWorkerAgents();
1328
+ const currentAgent = project.workerAgentId ?? this.defaultWorkerAgentId;
1329
+
1330
+ if (requestedAgent.toLowerCase() === "status") {
1331
+ return okReply(await this.buildWorkerStatusText(project, availableAgents));
1332
+ }
1333
+
1334
+ if (!requestedAgent) {
1335
+ const lines = [
1336
+ heading("Worker Agent"),
1337
+ "",
1338
+ `Current worker agent: \`${currentAgent}\``,
1339
+ `Default worker agent: \`${this.defaultWorkerAgentId}\``,
1340
+ availableAgents.length > 0 ? `Available agents: ${availableAgents.map((agentId) => `\`${agentId}\``).join(", ")}` : "",
1341
+ "",
1342
+ "Use `/clawspec worker <agent-id>` to change the ACP worker agent for this channel/project context.",
1343
+ ].filter(Boolean);
1344
+ return okReply(lines.join("\n"));
1345
+ }
1346
+
1347
+ if (hasBlockingExecution(project) || project.status === "planning") {
1348
+ return errorReply(BLOCKING_EXECUTION_MSG);
1349
+ }
1350
+
1351
+ if (availableAgents.length > 0 && !availableAgents.includes(requestedAgent)) {
1352
+ return errorReply(
1353
+ `Unknown worker agent \`${requestedAgent}\`. Available agents: ${availableAgents.map((agentId) => `\`${agentId}\``).join(", ")}`,
1354
+ );
1355
+ }
1356
+
1357
+ const updated = await this.stateStore.updateProject(channelKey, (current) => ({
1358
+ ...current,
1359
+ workerAgentId: requestedAgent,
1360
+ latestSummary: `Worker agent set to ${requestedAgent}.`,
1361
+ }));
1362
+
1363
+ return okReply(
1364
+ [
1365
+ heading("Worker Agent Updated"),
1366
+ "",
1367
+ `Worker agent: \`${updated.workerAgentId ?? this.defaultWorkerAgentId}\``,
1368
+ "Future background implementation turns will use this ACP agent.",
1369
+ ].join("\n"),
1370
+ );
1371
+ }
1372
+
1373
+ async queuePlanningProject(channelKey: string, mode: ExecutionMode): Promise<PluginCommandResult> {
1374
+ void mode;
1375
+ const prepared = await this.preparePlanningSync(channelKey);
1376
+ if ("result" in prepared) {
1377
+ return prepared.result;
1378
+ }
1379
+
1380
+ const updated = await this.stateStore.updateProject(channelKey, (current) => ({
1381
+ ...current,
1382
+ status: "ready",
1383
+ phase: current.phase === "planning_sync" ? "proposal" : current.phase,
1384
+ pauseRequested: false,
1385
+ cancelRequested: false,
1386
+ blockedReason: undefined,
1387
+ latestSummary: `Planning is ready to run for ${current.changeName}. Waiting for cs-plan in chat.`,
1388
+ execution: undefined,
1389
+ }));
1390
+
1391
+ return okReply(
1392
+ [
1393
+ heading("Planning Ready"),
1394
+ "",
1395
+ `Change: \`${updated.changeName}\``,
1396
+ "Planning now runs in the visible chat instead of the background worker.",
1397
+ "Next step: say `cs-plan` in this chat to refresh proposal/design/tasks.",
1398
+ "",
1399
+ formatCommandOutputSection(prepared.outputs),
1400
+ ].filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n"),
1401
+ );
1402
+ }
1403
+
1404
+ async queueWorkProject(channelKey: string, mode: ExecutionMode): Promise<PluginCommandResult> {
1405
+ const project = await this.requireActiveProject(channelKey);
1406
+ if (!project.repoPath || !project.projectName || !project.changeName) {
1407
+ return errorReply("Select a project and create a change first with `/clawspec use` and `/clawspec proposal`.");
1408
+ }
1409
+ if (!this.watcherManager) {
1410
+ return errorReply("ClawSpec watcher manager is not available.");
1411
+ }
1412
+ if (project.status === "planning" || project.execution?.action === "plan") {
1413
+ return errorReply(`Planning sync for \`${project.changeName}\` is still running. Wait for it to finish before starting implementation.`);
1414
+ }
1415
+ if (hasBlockingExecution(project)) {
1416
+ return errorReply(BLOCKING_EXECUTION_MSG);
1417
+ }
1418
+
1419
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
1420
+ const outputs: OpenSpecCommandResult[] = [];
1421
+ let statusResult: OpenSpecCommandResult<OpenSpecStatusResponse>;
1422
+ let applyResult: OpenSpecCommandResult<OpenSpecApplyInstructionsResponse>;
1423
+
1424
+ try {
1425
+ await this.ensureProjectSupportFiles(project);
1426
+ statusResult = await this.openSpec.status(project.repoPath, project.changeName);
1427
+ applyResult = await this.openSpec.instructionsApply(project.repoPath, project.changeName);
1428
+ outputs.push(statusResult, applyResult);
1429
+ } catch (error) {
1430
+ if (error instanceof OpenSpecCommandError) {
1431
+ return errorReply(
1432
+ [
1433
+ heading("Execution Preparation Failed"),
1434
+ "",
1435
+ `Change: \`${project.changeName}\``,
1436
+ "",
1437
+ formatCommandOutputSection([error.result]),
1438
+ ].join("\n"),
1439
+ );
1440
+ }
1441
+ throw error;
1442
+ }
1443
+
1444
+ const apply = applyResult.parsed!;
1445
+ const taskCounts = apply.progress;
1446
+ if (apply.state === "all_done") {
1447
+ await this.stateStore.updateProject(channelKey, (current) => ({
1448
+ ...current,
1449
+ status: "done",
1450
+ phase: "validating",
1451
+ taskCounts,
1452
+ latestSummary: `All tasks for ${current.changeName} are already complete.`,
1453
+ execution: undefined,
1454
+ }));
1455
+ return okReply(
1456
+ [
1457
+ heading("Implementation Complete"),
1458
+ "",
1459
+ `Change: \`${project.changeName}\``,
1460
+ `Schema: \`${statusResult.parsed?.schemaName ?? apply.schemaName}\``,
1461
+ `Progress: ${taskCounts.complete}/${taskCounts.total} tasks complete`,
1462
+ "",
1463
+ "All tasks are already complete. You can archive this change with `/clawspec archive`.",
1464
+ "",
1465
+ formatCommandOutputSection(outputs),
1466
+ ].join("\n"),
1467
+ );
1468
+ }
1469
+
1470
+ if (requiresPlanningSync(project) || apply.state === "blocked") {
1471
+ return errorReply(
1472
+ [
1473
+ heading("Planning Sync Required"),
1474
+ "",
1475
+ `Change: \`${project.changeName}\``,
1476
+ requiresPlanningSync(project)
1477
+ ? buildPlanningRequiredMessage(project)
1478
+ : buildPlanningBlockedMessage(project),
1479
+ "",
1480
+ formatCommandOutputSection(outputs),
1481
+ ].filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n"),
1482
+ );
1483
+ }
1484
+
1485
+ const armedAt = new Date().toISOString();
1486
+
1487
+ await removeIfExists(repoStatePaths.executionResultFile);
1488
+ const remainingTasks = apply.tasks.filter((task) => !task.done);
1489
+ const nextTask = remainingTasks[0];
1490
+ const nextProject = await this.stateStore.updateProject(channelKey, (current) => ({
1491
+ ...current,
1492
+ status: "armed",
1493
+ phase: "implementing",
1494
+ pauseRequested: false,
1495
+ cancelRequested: false,
1496
+ blockedReason: undefined,
1497
+ taskCounts,
1498
+ currentTask: nextTask ? `${nextTask.id} ${nextTask.description}` : undefined,
1499
+ latestSummary: `Execution queued for ${current.changeName}.`,
1500
+ lastNotificationKey: undefined,
1501
+ lastNotificationText: undefined,
1502
+ execution: {
1503
+ mode,
1504
+ action: "work",
1505
+ state: "armed",
1506
+ workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1507
+ workerSlot: "primary",
1508
+ armedAt,
1509
+ sessionKey: createWorkerSessionKey(current, {
1510
+ workerSlot: "primary",
1511
+ workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1512
+ attemptKey: armedAt,
1513
+ }),
1514
+ },
1515
+ }));
1516
+ await this.writeExecutionControl(nextProject);
1517
+ await this.watcherManager.wake(channelKey);
1518
+
1519
+ const remainingOverview = remainingTasks.slice(0, 5).map((task) => `- [ ] ${task.id} ${task.description}`);
1520
+ return okReply(
1521
+ [
1522
+ heading("Execution Queued"),
1523
+ "",
1524
+ `Change: \`${nextProject.changeName}\``,
1525
+ `Schema: \`${statusResult.parsed?.schemaName ?? apply.schemaName}\``,
1526
+ `Mode: \`${mode}\``,
1527
+ `Progress: ${taskCounts.complete}/${taskCounts.total} tasks complete`,
1528
+ `Planning journal: ${nextProject.planningJournal?.dirty ? "dirty" : "clean"}`,
1529
+ "Background implementation started. You will receive short progress updates here.",
1530
+ remainingOverview.length > 0 ? "" : "",
1531
+ remainingOverview.length > 0 ? "Remaining tasks overview:" : "",
1532
+ ...remainingOverview,
1533
+ "",
1534
+ "Next step: wait for progress updates or use `/clawspec pause` if you need to stop after the current safe boundary.",
1535
+ "",
1536
+ formatCommandOutputSection(outputs),
1537
+ ].filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n"),
1538
+ );
1539
+ }
1540
+
1541
+ async continueProject(channelKey: string): Promise<PluginCommandResult> {
1542
+ const project = await this.requireActiveProject(channelKey);
1543
+ if (!project.changeName || !project.repoPath) {
1544
+ return errorReply("No active change to continue.");
1545
+ }
1546
+
1547
+ if (
1548
+ project.phase === "planning_sync"
1549
+ || project.phase === "proposal"
1550
+ || project.status === "planning"
1551
+ || requiresPlanningSync(project)
1552
+ ) {
1553
+ return await this.queuePlanningProject(channelKey, "continue");
1554
+ }
1555
+
1556
+ return await this.queueWorkProject(channelKey, "continue");
1557
+ }
1558
+
1559
+ async armExecutionProject(channelKey: string, mode: ExecutionMode): Promise<PluginCommandResult> {
1560
+ return mode === "apply"
1561
+ ? await this.queueWorkProject(channelKey, mode)
1562
+ : await this.continueProject(channelKey);
1563
+ }
1564
+
1565
+ async pauseProject(channelKey: string): Promise<PluginCommandResult> {
1566
+ const project = await this.requireActiveProject(channelKey);
1567
+ if (!project.changeName || !project.repoPath) {
1568
+ return errorReply("No active change to pause.");
1569
+ }
1570
+ if (!this.watcherManager) {
1571
+ return errorReply("ClawSpec watcher manager is not available.");
1572
+ }
1573
+
1574
+ const hasBackgroundExecution = project.execution?.state === "armed"
1575
+ || project.execution?.state === "running"
1576
+ || project.status === "running"
1577
+ || (project.status === "planning" && project.execution?.action === "plan");
1578
+
1579
+ if (hasBackgroundExecution) {
1580
+ const updated = await this.stateStore.updateProject(channelKey, (current) => ({
1581
+ ...current,
1582
+ pauseRequested: true,
1583
+ cancelRequested: false,
1584
+ latestSummary: `Pause requested for ${current.changeName}.`,
1585
+ }));
1586
+ await this.writeExecutionControl(updated);
1587
+ if (updated.execution?.state === "running") {
1588
+ await this.watcherManager.interrupt(channelKey, "paused by user");
1589
+ }
1590
+ await this.watcherManager.wake(channelKey);
1591
+ return okReply(
1592
+ [
1593
+ heading("Pause Requested"),
1594
+ "",
1595
+ `Change: \`${project.changeName}\``,
1596
+ "Background execution will pause at the next safe boundary.",
1597
+ ].join("\n"),
1598
+ );
1599
+ }
1600
+
1601
+ if (project.status === "paused") {
1602
+ return okReply("Execution is already paused.");
1603
+ }
1604
+
1605
+ return errorReply("No armed or active background execution is available to pause.");
1606
+ }
1607
+
1608
+ async projectStatus(channelKey: string): Promise<PluginCommandResult> {
1609
+ let project = await this.requireActiveProject(channelKey);
1610
+ const outputs: OpenSpecCommandResult[] = [];
1611
+ let applyResult: OpenSpecApplyInstructionsResponse | undefined;
1612
+
1613
+ if (project.repoPath && project.changeName) {
1614
+ const recoveredSummary = await this.readMeaningfulExecutionSummary(
1615
+ getRepoStatePaths(project.repoPath, this.archiveDirName),
1616
+ );
1617
+ if (
1618
+ recoveredSummary
1619
+ && (!isMeaningfulExecutionSummary(project.latestSummary) || (project.status === "blocked" && !project.blockedReason))
1620
+ ) {
1621
+ project = await this.stateStore.updateProject(channelKey, (current) => ({
1622
+ ...current,
1623
+ blockedReason: current.status === "blocked"
1624
+ ? (current.blockedReason ?? recoveredSummary)
1625
+ : current.blockedReason,
1626
+ latestSummary: recoveredSummary,
1627
+ }));
1628
+ }
1629
+
1630
+ try {
1631
+ const statusResult = await this.openSpec.status(project.repoPath, project.changeName);
1632
+ outputs.push(statusResult);
1633
+ } catch (error) {
1634
+ if (error instanceof OpenSpecCommandError) {
1635
+ outputs.push(error.result);
1636
+ } else {
1637
+ throw error;
1638
+ }
1639
+ }
1640
+
1641
+ try {
1642
+ const applyInstructions = await this.openSpec.instructionsApply(project.repoPath, project.changeName);
1643
+ outputs.push(applyInstructions);
1644
+ applyResult = applyInstructions.parsed;
1645
+ project = await this.reconcileProjectFromApplyInstructions(channelKey, project, applyInstructions.parsed);
1646
+ } catch (error) {
1647
+ if (error instanceof OpenSpecCommandError) {
1648
+ outputs.push(error.result);
1649
+ } else {
1650
+ throw error;
1651
+ }
1652
+ }
1653
+ }
1654
+
1655
+ return okReply(await this.renderStatus(project, undefined, outputs, applyResult));
1656
+ }
1657
+
1658
+ async archiveProject(channelKey: string): Promise<PluginCommandResult> {
1659
+ const project = await this.requireActiveProject(channelKey);
1660
+ if (!project.repoPath || !project.projectName || !project.changeName) {
1661
+ return errorReply("No active change to archive.");
1662
+ }
1663
+ if (hasBlockingExecution(project)) {
1664
+ return errorReply(BLOCKING_EXECUTION_MSG);
1665
+ }
1666
+
1667
+ const taskCounts = await this.loadTaskCounts(project);
1668
+ if ((taskCounts?.remaining ?? 1) > 0) {
1669
+ return errorReply("Not all tasks are complete yet. Finish implementation or use `/clawspec status` to inspect progress.");
1670
+ }
1671
+
1672
+ const outputs: OpenSpecCommandResult[] = [];
1673
+ try {
1674
+ const validateResult = await this.openSpec.validate(project.repoPath, project.changeName);
1675
+ outputs.push(validateResult);
1676
+ const archivePath = await this.writeArchiveBundle(project, taskCounts!);
1677
+ const archiveResult = await this.openSpec.archive(project.repoPath, project.changeName);
1678
+ outputs.push(archiveResult);
1679
+
1680
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
1681
+ const rollbackStore = new RollbackStore(project.repoPath, this.archiveDirName, project.changeName);
1682
+ await rollbackStore.clear();
1683
+ await this.clearChangeRuntimeFiles(repoStatePaths);
1684
+ await this.resetRunSupportFiles(repoStatePaths, `Archived change ${project.changeName}.`);
1685
+
1686
+ await this.stateStore.updateProject(channelKey, (current) => ({
1687
+ ...current,
1688
+ status: "archived",
1689
+ phase: "archiving",
1690
+ changeName: undefined,
1691
+ changeDir: undefined,
1692
+ description: undefined,
1693
+ currentTask: undefined,
1694
+ taskCounts: undefined,
1695
+ pauseRequested: false,
1696
+ cancelRequested: false,
1697
+ blockedReason: undefined,
1698
+ latestSummary: `Archived change ${project.changeName}.`,
1699
+ execution: undefined,
1700
+ planningJournal: {
1701
+ dirty: false,
1702
+ entryCount: 0,
1703
+ },
1704
+ rollback: undefined,
1705
+ archivePath,
1706
+ }));
1707
+
1708
+ return okReply(
1709
+ [
1710
+ heading("Archive Complete"),
1711
+ "",
1712
+ `Project: \`${project.projectName}\``,
1713
+ `Archive bundle: \`${archivePath}\``,
1714
+ "",
1715
+ formatCommandOutputSection(outputs),
1716
+ ].filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n"),
1717
+ );
1718
+ } catch (error) {
1719
+ if (error instanceof OpenSpecCommandError) {
1720
+ return errorReply(
1721
+ [
1722
+ heading("Archive Failed"),
1723
+ "",
1724
+ `Change: \`${project.changeName}\``,
1725
+ "",
1726
+ formatCommandOutputSection([error.result]),
1727
+ ].join("\n"),
1728
+ );
1729
+ }
1730
+ throw error;
1731
+ }
1732
+ }
1733
+
1734
+ async cancelProject(channelKey: string): Promise<PluginCommandResult> {
1735
+ const project = await this.requireActiveProject(channelKey);
1736
+ if (!project.repoPath || !project.projectName || !project.changeName) {
1737
+ return errorReply("No active change to cancel.");
1738
+ }
1739
+
1740
+ const hasBackgroundExecution = project.execution?.state === "armed"
1741
+ || project.execution?.state === "running"
1742
+ || project.status === "running"
1743
+ || (project.status === "planning" && project.execution?.action === "plan");
1744
+
1745
+ if (this.watcherManager && hasBackgroundExecution) {
1746
+ const updated = await this.stateStore.updateProject(channelKey, (current) => ({
1747
+ ...current,
1748
+ cancelRequested: true,
1749
+ pauseRequested: false,
1750
+ latestSummary: `Cancellation requested for ${current.changeName}.`,
1751
+ }));
1752
+ await this.writeExecutionControl(updated);
1753
+ if (updated.execution?.state === "running") {
1754
+ await this.watcherManager.interrupt(channelKey, "cancelled by user");
1755
+ }
1756
+ await this.watcherManager.wake(channelKey);
1757
+ return okReply(
1758
+ [
1759
+ heading("Cancellation Requested"),
1760
+ "",
1761
+ `Change: \`${project.changeName}\``,
1762
+ "Background execution will stop at the next safe boundary, then cleanup will run.",
1763
+ ].join("\n"),
1764
+ );
1765
+ }
1766
+
1767
+ await this.finalizeCancellation(project);
1768
+ return okReply(
1769
+ [
1770
+ heading("Change Cancelled"),
1771
+ "",
1772
+ `Project: \`${project.projectName}\``,
1773
+ "Rollback restored tracked files, removed the change directory, and cleared change-scoped runtime files.",
1774
+ ].join("\n"),
1775
+ );
1776
+ }
1777
+
1778
+ private async captureIncomingMessage(channelKey: string, project: ProjectState, text: string): Promise<ProjectState> {
1779
+ const trimmed = sanitizePlanningMessageText(text).trim();
1780
+ if (!trimmed || trimmed.startsWith("/clawspec") || !project.repoPath || !project.changeName) {
1781
+ return project;
1782
+ }
1783
+ if (!isProjectContextAttached(project)) {
1784
+ return project;
1785
+ }
1786
+ if (parseClawSpecKeyword(trimmed)) {
1787
+ return project;
1788
+ }
1789
+
1790
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
1791
+ const timestamp = new Date().toISOString();
1792
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
1793
+ const shouldAppendJournal = !isExecutionTriggerText(trimmed);
1794
+
1795
+ await this.stateStore.updateProject(channelKey, (current) => {
1796
+ if (current.execution?.state !== "armed") {
1797
+ return current;
1798
+ }
1799
+ return {
1800
+ ...current,
1801
+ execution: {
1802
+ ...current.execution,
1803
+ triggerPrompt: trimmed,
1804
+ lastTriggerAt: timestamp,
1805
+ },
1806
+ };
1807
+ });
1808
+
1809
+ if (!shouldAppendJournal || !shouldCapturePlanningMessage(project)) {
1810
+ return project;
1811
+ }
1812
+
1813
+ await this.ensureProjectSupportFiles(project);
1814
+ const existingEntries = await journalStore.list(project.changeName);
1815
+ const lastEntry = existingEntries[existingEntries.length - 1];
1816
+ if (lastEntry?.role === "user" && lastEntry.text === trimmed) {
1817
+ return project;
1818
+ }
1819
+
1820
+ await journalStore.append({
1821
+ timestamp,
1822
+ changeName: project.changeName,
1823
+ role: "user",
1824
+ text: trimmed,
1825
+ });
1826
+
1827
+ return await this.stateStore.updateProject(channelKey, (current) => ({
1828
+ ...current,
1829
+ planningJournal: {
1830
+ dirty: true,
1831
+ entryCount: (current.planningJournal?.entryCount ?? 0) + 1,
1832
+ lastEntryAt: timestamp,
1833
+ lastSyncedAt: current.planningJournal?.lastSyncedAt,
1834
+ },
1835
+ }));
1836
+ }
1837
+
1838
+ private shouldIgnoreInboundPlanningMessage(params: {
1839
+ channelId?: string;
1840
+ accountId?: string;
1841
+ conversationId?: string;
1842
+ metadata?: Record<string, unknown>;
1843
+ }, text: string): boolean {
1844
+ const normalized = sanitizePlanningMessageText(text).trim();
1845
+ if (!normalized || !params.channelId) {
1846
+ return true;
1847
+ }
1848
+
1849
+ const metadata = params.metadata;
1850
+ const selfFlags = [
1851
+ metadata?.fromSelf,
1852
+ metadata?.isSelf,
1853
+ metadata?.self,
1854
+ metadata?.isBot,
1855
+ metadata?.fromBot,
1856
+ metadata?.bot,
1857
+ ];
1858
+ if (selfFlags.some((value) => value === true)) {
1859
+ return true;
1860
+ }
1861
+
1862
+ const scopeKey = this.buildMessageScopeKey(params);
1863
+ const now = Date.now();
1864
+ const entries = (this.recentOutboundMessages.get(scopeKey) ?? [])
1865
+ .filter((entry) => now - entry.timestamp < 120_000);
1866
+ if (entries.length === 0) {
1867
+ return false;
1868
+ }
1869
+ this.recentOutboundMessages.set(scopeKey, entries);
1870
+ return entries.some((entry) => entry.text === normalized);
1871
+ }
1872
+
1873
+ private buildMessageScopeKey(params: {
1874
+ channelId?: string;
1875
+ accountId?: string;
1876
+ conversationId?: string;
1877
+ }): string {
1878
+ return [
1879
+ params.channelId ?? "",
1880
+ params.accountId ?? "default",
1881
+ params.conversationId ?? "main",
1882
+ ].join(":");
1883
+ }
1884
+
1885
+ private async ensureSessionProject(channelKey: string, workspacePath: string): Promise<ProjectState> {
1886
+ const existing = await this.stateStore.getActiveProject(channelKey);
1887
+ if (existing) {
1888
+ if (existing.workspacePath && existing.workerAgentId) {
1889
+ return existing;
1890
+ }
1891
+ return await this.stateStore.updateProject(channelKey, (current) => ({
1892
+ ...current,
1893
+ contextMode: current.contextMode ?? "attached",
1894
+ workspacePath: current.workspacePath ?? workspacePath,
1895
+ workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1896
+ status: current.status || "idle",
1897
+ phase: current.phase || "init",
1898
+ }));
1899
+ }
1900
+
1901
+ const created = await this.stateStore.createProject(channelKey);
1902
+ return await this.stateStore.updateProject(channelKey, (current) => ({
1903
+ ...created,
1904
+ ...current,
1905
+ contextMode: current.contextMode ?? "attached",
1906
+ workspacePath,
1907
+ workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1908
+ status: "idle",
1909
+ phase: "init",
1910
+ }));
1911
+ }
1912
+
1913
+ private listAvailableWorkerAgents(): string[] {
1914
+ const config = this.config as Record<string, unknown>;
1915
+ const acp = config.acp as Record<string, unknown> | undefined;
1916
+ const allowed = Array.isArray(acp?.allowedAgents)
1917
+ ? acp.allowedAgents.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
1918
+ : [];
1919
+ const agentsConfig = config.agents as Record<string, unknown> | undefined;
1920
+ const listed = Array.isArray(agentsConfig?.list)
1921
+ ? agentsConfig.list
1922
+ .map((entry) => (entry && typeof entry === "object" ? (entry as Record<string, unknown>).id : undefined))
1923
+ .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
1924
+ : [];
1925
+ return Array.from(new Set([...allowed, ...listed])).sort((left, right) => left.localeCompare(right));
1926
+ }
1927
+
1928
+ private async buildWorkerStatusText(project: ProjectState, availableAgents: string[]): Promise<string> {
1929
+ const configuredAgent = project.workerAgentId ?? this.defaultWorkerAgentId;
1930
+ const execution = project.execution;
1931
+ const taskCounts = project.taskCounts;
1932
+ const runtimeStatus = this.watcherManager?.getWorkerRuntimeStatus
1933
+ ? await this.watcherManager.getWorkerRuntimeStatus(project)
1934
+ : undefined;
1935
+ const transportMode = describeWorkerTransportMode(project);
1936
+ const runtimeState = describeWorkerRuntimeState(project, runtimeStatus);
1937
+ const startupWait = describeExecutionStartupWait(execution);
1938
+ const runtimePid = typeof runtimeStatus?.details?.pid === "number" && Number.isFinite(runtimeStatus.details.pid)
1939
+ ? runtimeStatus.details.pid
1940
+ : undefined;
1941
+ const nextAction = execution?.action === "plan"
1942
+ ? "Planning is active. Let the current chat turn finish."
1943
+ : execution?.action === "work"
1944
+ ? "Wait for worker updates or use `/clawspec pause`."
1945
+ : !isProjectContextAttached(project)
1946
+ ? "Use `/clawspec attach` or `cs-attach` when you want ordinary chat to re-enter project mode."
1947
+ : project.status === "ready" && project.phase === "proposal"
1948
+ ? "Keep describing requirements, then run `cs-plan`."
1949
+ : project.status === "ready" && project.phase === "tasks"
1950
+ ? "Run `cs-work` when you want implementation to start."
1951
+ : project.status === "blocked"
1952
+ ? "Review the blocker, then use `cs-plan`, `cs-work`, or `/clawspec continue` as appropriate."
1953
+ : project.status === "paused"
1954
+ ? "Run `/clawspec continue` when you are ready to resume."
1955
+ : "Idle.";
1956
+ const lines = [
1957
+ heading("Worker Status"),
1958
+ "",
1959
+ `Project: \`${project.projectName ?? "none"}\``,
1960
+ `Change: \`${project.changeName ?? "none"}\``,
1961
+ `Context: \`${isProjectContextAttached(project) ? "attached" : "detached"}\``,
1962
+ `Phase: \`${project.phase}\``,
1963
+ `Lifecycle: \`${project.status}\``,
1964
+ `Configured worker agent: \`${configuredAgent}\``,
1965
+ `Default worker agent: \`${this.defaultWorkerAgentId}\``,
1966
+ availableAgents.length > 0 ? `Available agents: ${availableAgents.map((agentId) => `\`${agentId}\``).join(", ")}` : "",
1967
+ `Execution state: \`${execution?.state ?? "idle"}\``,
1968
+ `Worker transport: \`${transportMode}\``,
1969
+ `Action: \`${execution?.action ?? "none"}\``,
1970
+ `Worker slot: \`${execution?.workerSlot ?? "primary"}\``,
1971
+ execution?.workerAgentId ? `Running agent: \`${execution.workerAgentId}\`` : "",
1972
+ execution?.startupPhase ? `Startup phase: \`${execution.startupPhase}\`` : "",
1973
+ execution?.connectedAt ? `Connected at: \`${execution.connectedAt}\`` : "",
1974
+ execution?.firstProgressAt ? `First visible progress: \`${execution.firstProgressAt}\`` : "",
1975
+ startupWait ? `Startup wait: \`${startupWait}\`` : "",
1976
+ execution?.currentArtifact ? `Current artifact: \`${execution.currentArtifact}\`` : "",
1977
+ execution?.currentTaskId ? `Current task: \`${execution.currentTaskId}\`` : "",
1978
+ taskCounts ? `Progress: ${taskCounts.complete}/${taskCounts.total} complete, ${taskCounts.remaining} remaining` : "",
1979
+ execution?.sessionKey ? `Session: \`${execution.sessionKey}\`` : "",
1980
+ `Runtime status: \`${runtimeState}\``,
1981
+ runtimePid != null ? `Runtime pid: \`${runtimePid}\`` : "",
1982
+ runtimeStatus?.summary ? `Runtime summary: ${runtimeStatus.summary}` : "",
1983
+ execution?.lastHeartbeatAt ? `Last heartbeat: \`${execution.lastHeartbeatAt}\`` : "",
1984
+ execution?.restartCount ? `Restart attempts: \`${execution.restartCount}\`` : "",
1985
+ execution?.progressOffset != null ? `Progress offset: \`${execution.progressOffset}\`` : "",
1986
+ execution?.lastFailure ? `Last worker failure: ${execution.lastFailure}` : "",
1987
+ project.latestSummary ? `Latest summary: ${project.latestSummary}` : "",
1988
+ `Next: ${nextAction}`,
1989
+ ];
1990
+ return lines.filter(Boolean).join("\n");
1991
+ }
1992
+
1993
+ private async findUnfinishedProjectForRepo(
1994
+ repoPath: string,
1995
+ excludeProjectId?: string,
1996
+ ): Promise<{ channelKey: string; project: ProjectState } | null> {
1997
+ const projects = dedupeProjects(await this.stateStore.listActiveProjects());
1998
+ return projects.find((entry) =>
1999
+ samePath(entry.project.repoPath, repoPath)
2000
+ && !isFinishedStatus(entry.project.status)
2001
+ && entry.project.projectId !== excludeProjectId
2002
+ ) ?? null;
2003
+ }
2004
+
2005
+ private async requireActiveProject(channelKey: string): Promise<ProjectState> {
2006
+ const project = await this.stateStore.getActiveProject(channelKey);
2007
+ if (!project) {
2008
+ throw new Error("No active project in this channel. Start one with `/clawspec workspace` and `/clawspec use`.");
2009
+ }
2010
+ return project;
2011
+ }
2012
+
2013
+ private async resolveArmedProjectForPrompt(prompt: string, sessionKey?: string): Promise<ProjectState | undefined> {
2014
+ const projects = dedupeProjects(await this.stateStore.listActiveProjects()).map((entry) => entry.project);
2015
+ const promptCandidates = collectPromptCandidates(prompt);
2016
+
2017
+ const bySession = projects.find((project) =>
2018
+ project.execution?.state === "armed"
2019
+ && sessionKey
2020
+ && matchesExecutionSession(project, sessionKey)
2021
+ );
2022
+ if (bySession) {
2023
+ return bySession;
2024
+ }
2025
+
2026
+ const promptMatches = projects
2027
+ .filter((project) =>
2028
+ project.execution?.state === "armed"
2029
+ && promptCandidates.some((candidate) => project.execution?.triggerPrompt === candidate)
2030
+ )
2031
+ .sort((left, right) => (right.execution?.lastTriggerAt ?? "").localeCompare(left.execution?.lastTriggerAt ?? ""));
2032
+ if (promptMatches.length > 0) {
2033
+ if (promptMatches.length > 1) {
2034
+ this.logger.warn(`[clawspec] multiple armed projects matched trigger prompt "${prompt}", using the most recent match.`);
2035
+ }
2036
+ return promptMatches[0];
2037
+ }
2038
+
2039
+ const allArmed = projects.filter((project) => project.execution?.state === "armed");
2040
+ return allArmed.length === 1 ? allArmed[0] : undefined;
2041
+ }
2042
+
2043
+ private async findRunningProjectBySessionKey(sessionKey?: string): Promise<ProjectState | undefined> {
2044
+ if (!sessionKey) {
2045
+ return undefined;
2046
+ }
2047
+ const projects = await this.stateStore.listActiveProjects();
2048
+ return projects.find((project) =>
2049
+ project.execution?.state === "running"
2050
+ && matchesExecutionSession(project, sessionKey)
2051
+ );
2052
+ }
2053
+
2054
+ private async findPlanningProjectBySessionKey(sessionKey?: string): Promise<ProjectState | undefined> {
2055
+ if (!sessionKey) {
2056
+ return undefined;
2057
+ }
2058
+ const projects = dedupeProjects(await this.stateStore.listActiveProjects()).map((entry) => entry.project);
2059
+ return projects.find((project) =>
2060
+ project.status === "planning"
2061
+ && project.phase === "planning_sync"
2062
+ && project.boundSessionKey === sessionKey
2063
+ );
2064
+ }
2065
+
2066
+ private async findDiscussionProjectBySessionKey(sessionKey?: string): Promise<ProjectState | undefined> {
2067
+ if (!sessionKey) {
2068
+ return undefined;
2069
+ }
2070
+ const projects = dedupeProjects(await this.stateStore.listActiveProjects()).map((entry) => entry.project);
2071
+ return projects.find((project) =>
2072
+ project.boundSessionKey === sessionKey
2073
+ && Boolean(project.repoPath)
2074
+ && Boolean(project.changeName)
2075
+ && isProjectContextAttached(project)
2076
+ && !hasBlockingExecution(project)
2077
+ && project.status !== "planning"
2078
+ && !isFinishedStatus(project.status)
2079
+ );
2080
+ }
2081
+
2082
+ private async captureAssistantPlanningMessage(project: ProjectState, event: AgentEndEvent): Promise<void> {
2083
+ if (!project.repoPath || !project.changeName || !event.success) {
2084
+ return;
2085
+ }
2086
+
2087
+ const latestUserText = sanitizePlanningMessageText(extractLatestMessageTextByRole(event.messages, "user") ?? "").trim();
2088
+ if (
2089
+ !latestUserText
2090
+ || latestUserText.startsWith("/clawspec")
2091
+ || Boolean(parseClawSpecKeyword(latestUserText))
2092
+ || isExecutionTriggerText(latestUserText)
2093
+ ) {
2094
+ return;
2095
+ }
2096
+
2097
+ const latestAssistantText = sanitizePlanningMessageText(extractLatestMessageTextByRole(event.messages, "assistant") ?? "").trim();
2098
+ if (!latestAssistantText || isPassiveAssistantPlanningMessage(latestAssistantText)) {
2099
+ return;
2100
+ }
2101
+
2102
+ await this.ensureProjectSupportFiles(project);
2103
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
2104
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
2105
+ const existingEntries = await journalStore.list(project.changeName);
2106
+ const lastEntry = existingEntries[existingEntries.length - 1];
2107
+ if (lastEntry?.role === "assistant" && lastEntry.text === latestAssistantText) {
2108
+ return;
2109
+ }
2110
+
2111
+ const timestamp = new Date().toISOString();
2112
+ await journalStore.append({
2113
+ timestamp,
2114
+ changeName: project.changeName,
2115
+ role: "assistant",
2116
+ text: latestAssistantText,
2117
+ });
2118
+
2119
+ await this.stateStore.updateProject(project.channelKey, (current) => ({
2120
+ ...current,
2121
+ planningJournal: {
2122
+ dirty: true,
2123
+ entryCount: (current.planningJournal?.entryCount ?? 0) + 1,
2124
+ lastEntryAt: timestamp,
2125
+ lastSyncedAt: current.planningJournal?.lastSyncedAt,
2126
+ },
2127
+ }));
2128
+ }
2129
+
2130
+ private async finalizePlanningTurn(project: ProjectState, event: AgentEndEvent): Promise<void> {
2131
+ if (!project.repoPath || !project.changeName) {
2132
+ return;
2133
+ }
2134
+
2135
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
2136
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
2137
+ const timestamp = new Date().toISOString();
2138
+ let status: ProjectState["status"] = "ready";
2139
+ let phase: ProjectState["phase"] = "tasks";
2140
+ let blockedReason: string | undefined;
2141
+ let latestSummary = `Planning sync finished for ${project.changeName}. Say \`cs-work\` to start implementation.`;
2142
+ let taskCounts = project.taskCounts;
2143
+ let currentTask = project.currentTask;
2144
+ let journalDirty = false;
2145
+ let lastSyncedAt = timestamp;
2146
+
2147
+ await removeIfExists(repoStatePaths.executionControlFile);
2148
+ await removeIfExists(repoStatePaths.executionResultFile);
2149
+ await removeIfExists(repoStatePaths.workerProgressFile);
2150
+
2151
+ if (!event.success) {
2152
+ status = "blocked";
2153
+ phase = "planning_sync";
2154
+ blockedReason = `Planning sync failed: ${event.error ?? "unknown error"}`;
2155
+ latestSummary = blockedReason;
2156
+ journalDirty = true;
2157
+ lastSyncedAt = project.planningJournal?.lastSyncedAt;
2158
+ } else {
2159
+ try {
2160
+ const apply = (await this.openSpec.instructionsApply(project.repoPath, project.changeName)).parsed;
2161
+ taskCounts = apply.progress;
2162
+ const nextTask = apply.tasks.find((task) => !task.done);
2163
+ currentTask = nextTask ? `${nextTask.id} ${nextTask.description}` : undefined;
2164
+
2165
+ if (apply.state === "blocked") {
2166
+ status = "blocked";
2167
+ phase = "proposal";
2168
+ blockedReason = buildPlanningBlockedMessage(project);
2169
+ latestSummary = blockedReason;
2170
+ journalDirty = true;
2171
+ lastSyncedAt = project.planningJournal?.lastSyncedAt;
2172
+ } else if (apply.state === "all_done") {
2173
+ status = "done";
2174
+ phase = "validating";
2175
+ latestSummary = `Planning sync finished and all tasks for ${project.changeName} are already complete.`;
2176
+ currentTask = undefined;
2177
+ }
2178
+ } catch (error) {
2179
+ status = "blocked";
2180
+ phase = "planning_sync";
2181
+ blockedReason = error instanceof OpenSpecCommandError
2182
+ ? `Planning sync finished, but \`${error.result.command}\` failed. Review the OpenSpec output and run \`cs-plan\` again.`
2183
+ : `Planning sync finished, but apply readiness could not be checked: ${error instanceof Error ? error.message : String(error)}`;
2184
+ latestSummary = blockedReason;
2185
+ journalDirty = true;
2186
+ lastSyncedAt = project.planningJournal?.lastSyncedAt;
2187
+ }
2188
+ }
2189
+
2190
+ if (!journalDirty) {
2191
+ await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, project.changeName, timestamp);
2192
+ }
2193
+ await this.writeLatestSummary(repoStatePaths, latestSummary);
2194
+
2195
+ await this.stateStore.updateProject(project.channelKey, (current) => ({
2196
+ ...current,
2197
+ status,
2198
+ phase,
2199
+ blockedReason,
2200
+ taskCounts,
2201
+ currentTask,
2202
+ latestSummary,
2203
+ pauseRequested: false,
2204
+ cancelRequested: false,
2205
+ execution: undefined,
2206
+ boundSessionKey: current.boundSessionKey,
2207
+ planningJournal: {
2208
+ dirty: journalDirty,
2209
+ entryCount: current.planningJournal?.entryCount ?? 0,
2210
+ lastEntryAt: current.planningJournal?.lastEntryAt,
2211
+ lastSyncedAt,
2212
+ },
2213
+ }));
2214
+ }
2215
+
2216
+ private resolvePostRunStatus(
2217
+ project: ProjectState,
2218
+ result: ExecutionResult | null,
2219
+ taskCounts: TaskCountSummary | undefined,
2220
+ event: AgentEndEvent,
2221
+ ): ProjectState["status"] {
2222
+ void project;
2223
+
2224
+ if (result?.status === "done" || (taskCounts?.remaining ?? 1) === 0) {
2225
+ return "done";
2226
+ }
2227
+ if (result?.status === "paused") {
2228
+ return "paused";
2229
+ }
2230
+ if (result?.status === "blocked") {
2231
+ return "blocked";
2232
+ }
2233
+ if (result?.status === "running") {
2234
+ return "ready";
2235
+ }
2236
+ if (!event.success) {
2237
+ return "blocked";
2238
+ }
2239
+ return "ready";
2240
+ }
2241
+
2242
+ private async writeExecutionControl(project: ProjectState): Promise<void> {
2243
+ if (!project.repoPath || !project.changeName || !project.execution) {
2244
+ return;
2245
+ }
2246
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
2247
+ await writeJsonFile(repoStatePaths.executionControlFile, this.buildExecutionControl(project));
2248
+ }
2249
+
2250
+ private buildExecutionControl(project: ProjectState): ExecutionControlFile {
2251
+ const execution = project.execution;
2252
+ return {
2253
+ version: 1,
2254
+ changeName: project.changeName ?? "",
2255
+ mode: execution?.mode ?? "apply",
2256
+ state: execution?.state ?? "armed",
2257
+ armedAt: execution?.armedAt ?? new Date().toISOString(),
2258
+ startedAt: execution?.startedAt,
2259
+ sessionKey: execution?.sessionKey ?? project.boundSessionKey,
2260
+ pauseRequested: project.pauseRequested,
2261
+ cancelRequested: project.cancelRequested === true,
2262
+ };
2263
+ }
2264
+
2265
+ private async loadTaskCounts(project: ProjectState): Promise<TaskCountSummary | undefined> {
2266
+ if (!project.repoPath || !project.changeName) {
2267
+ return project.taskCounts;
2268
+ }
2269
+ const tasksPath = getTasksPath(project.repoPath, project.changeName);
2270
+ if (!(await pathExists(tasksPath))) {
2271
+ return project.taskCounts;
2272
+ }
2273
+ return (await parseTasksFile(tasksPath)).counts;
2274
+ }
2275
+
2276
+ private async writeArchiveBundle(project: ProjectState, taskCounts: TaskCountSummary): Promise<string> {
2277
+ const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
2278
+ const archivePath = path.join(repoStatePaths.archivesRoot, project.projectId);
2279
+ await ensureDir(archivePath);
2280
+
2281
+ const resumeContext = [
2282
+ "# Resume Context",
2283
+ "",
2284
+ `Project: ${project.projectTitle ?? project.projectName ?? project.projectId}`,
2285
+ `Repo path: ${project.repoPath}`,
2286
+ `Change name: ${project.changeName ?? "_unknown_"}`,
2287
+ `Completed tasks: ${taskCounts.complete}`,
2288
+ `Remaining tasks: ${taskCounts.remaining}`,
2289
+ `Latest summary: ${project.latestSummary ?? "_none_"}`,
2290
+ ].join("\n");
2291
+ await writeUtf8(path.join(archivePath, "resume-context.md"), `${resumeContext}\n`);
2292
+
2293
+ const sessionSummary = [
2294
+ "# Session Summary",
2295
+ "",
2296
+ `Project id: ${project.projectId}`,
2297
+ `Project: ${project.projectName ?? project.projectId}`,
2298
+ `Change: ${project.changeName ?? "_none_"}`,
2299
+ `Task counts: ${taskCounts.complete}/${taskCounts.total}`,
2300
+ `Latest summary: ${project.latestSummary ?? "_none_"}`,
2301
+ ].join("\n");
2302
+ await writeUtf8(path.join(archivePath, "session-summary.md"), `${sessionSummary}\n`);
2303
+
2304
+ const changedFiles = (await tryReadUtf8(repoStatePaths.changedFilesFile)) ?? "# Changed Files\n";
2305
+ await writeUtf8(path.join(archivePath, "changed-files.md"), changedFiles);
2306
+
2307
+ const decisionLog = (await tryReadUtf8(repoStatePaths.decisionLogFile)) ?? "# Decision Log\n";
2308
+ await writeUtf8(path.join(archivePath, "decision-log.md"), decisionLog);
2309
+
2310
+ await writeJsonFile(path.join(archivePath, "run-metadata.json"), {
2311
+ projectId: project.projectId,
2312
+ projectName: project.projectName,
2313
+ repoPath: project.repoPath,
2314
+ changeName: project.changeName,
2315
+ taskCounts,
2316
+ latestSummary: project.latestSummary,
2317
+ archivedAt: new Date().toISOString(),
2318
+ });
2319
+
2320
+ return archivePath;
2321
+ }
2322
+
2323
+ private async updateSupportFilesFromExecutionResult(project: ProjectState, result: ExecutionResult): Promise<void> {
2324
+ if (!project.repoPath) {
2325
+ return;
2326
+ }
2327
+
2328
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
2329
+ const progressBlock = [
2330
+ `## ${result.timestamp}`,
2331
+ "",
2332
+ `- status: ${result.status}`,
2333
+ `- summary: ${result.summary}`,
2334
+ result.completedTask ? `- completed: ${result.completedTask}` : "",
2335
+ result.currentArtifact ? `- artifact: ${result.currentArtifact}` : "",
2336
+ typeof result.remainingTasks === "number" ? `- remaining: ${result.remainingTasks}` : "",
2337
+ "",
2338
+ ]
2339
+ .filter((line) => line !== "")
2340
+ .join("\n");
2341
+ await appendUtf8(repoStatePaths.progressFile, `${progressBlock}\n`);
2342
+
2343
+ await this.mergeChangedFiles(repoStatePaths, result.changedFiles);
2344
+ if (result.notes.length > 0) {
2345
+ const notesBlock = [
2346
+ `## ${result.timestamp}`,
2347
+ "",
2348
+ ...result.notes.map((note) => `- ${note}`),
2349
+ "",
2350
+ ].join("\n");
2351
+ await appendUtf8(repoStatePaths.decisionLogFile, notesBlock);
2352
+ }
2353
+
2354
+ await this.writeLatestSummary(repoStatePaths, result.summary);
2355
+
2356
+ if (project.changeName && result.changedFiles.length > 0) {
2357
+ const rollbackStore = new RollbackStore(project.repoPath, this.archiveDirName, project.changeName);
2358
+ const manifest = await rollbackStore.readManifest();
2359
+ if (manifest) {
2360
+ await rollbackStore.recordTouchedFiles(result.changedFiles);
2361
+ }
2362
+ }
2363
+ }
2364
+
2365
+ private async mergeChangedFiles(repoStatePaths: RepoStatePaths, changedFiles: string[]): Promise<void> {
2366
+ const existing = ((await tryReadUtf8(repoStatePaths.changedFilesFile)) ?? "")
2367
+ .split(/\r?\n/)
2368
+ .map((line) => line.replace(/^- /, "").trim())
2369
+ .filter((line) => line.length > 0 && line !== "# Changed Files");
2370
+ const merged = new Set(existing);
2371
+ changedFiles.forEach((entry) => {
2372
+ const normalized = normalizeSlashes(entry).replace(/^\.\//, "");
2373
+ if (normalized) {
2374
+ merged.add(normalized);
2375
+ }
2376
+ });
2377
+
2378
+ const body = ["# Changed Files", ""]
2379
+ .concat(Array.from(merged).sort((left, right) => left.localeCompare(right)).map((entry) => `- ${entry}`))
2380
+ .join("\n");
2381
+ await writeUtf8(repoStatePaths.changedFilesFile, `${body}\n`);
2382
+ }
2383
+
2384
+ private async writeLatestSummary(repoStatePaths: RepoStatePaths, summary: string): Promise<void> {
2385
+ await writeUtf8(repoStatePaths.latestSummaryFile, `${summary}\n`);
2386
+ }
2387
+
2388
+ private async reconcileProjectFromApplyInstructions(
2389
+ channelKey: string,
2390
+ project: ProjectState,
2391
+ apply: OpenSpecApplyInstructionsResponse,
2392
+ ): Promise<ProjectState> {
2393
+ const nextTask = apply.tasks.find((task) => !task.done);
2394
+ const nextCurrentTask = nextTask ? `${nextTask.id} ${nextTask.description}` : undefined;
2395
+
2396
+ if (project.status === "planning" && project.phase === "planning_sync" && project.planningJournal?.dirty !== true) {
2397
+ if (apply.state === "all_done") {
2398
+ return await this.stateStore.updateProject(channelKey, (current) => ({
2399
+ ...current,
2400
+ status: "done",
2401
+ phase: "validating",
2402
+ blockedReason: undefined,
2403
+ taskCounts: apply.progress,
2404
+ currentTask: undefined,
2405
+ latestSummary: `Planning sync finished and all tasks for ${current.changeName} are already complete.`,
2406
+ planningJournal: {
2407
+ dirty: false,
2408
+ entryCount: current.planningJournal?.entryCount ?? 0,
2409
+ lastEntryAt: current.planningJournal?.lastEntryAt,
2410
+ lastSyncedAt: new Date().toISOString(),
2411
+ },
2412
+ }));
2413
+ }
2414
+
2415
+ if (apply.state === "ready") {
2416
+ return await this.stateStore.updateProject(channelKey, (current) => ({
2417
+ ...current,
2418
+ status: "ready",
2419
+ phase: "tasks",
2420
+ blockedReason: undefined,
2421
+ taskCounts: apply.progress,
2422
+ currentTask: nextCurrentTask,
2423
+ latestSummary: `Planning sync finished for ${current.changeName}. Say \`cs-work\` to start implementation.`,
2424
+ planningJournal: {
2425
+ dirty: false,
2426
+ entryCount: current.planningJournal?.entryCount ?? 0,
2427
+ lastEntryAt: current.planningJournal?.lastEntryAt,
2428
+ lastSyncedAt: new Date().toISOString(),
2429
+ },
2430
+ }));
2431
+ }
2432
+
2433
+ if (apply.state === "blocked") {
2434
+ return await this.stateStore.updateProject(channelKey, (current) => ({
2435
+ ...current,
2436
+ status: "blocked",
2437
+ phase: "proposal",
2438
+ blockedReason: buildPlanningBlockedMessage(current),
2439
+ taskCounts: apply.progress,
2440
+ currentTask: nextCurrentTask,
2441
+ latestSummary: buildPlanningBlockedMessage(current),
2442
+ }));
2443
+ }
2444
+ }
2445
+
2446
+ if (project.status === "blocked" && apply.state === "all_done") {
2447
+ return await this.stateStore.updateProject(channelKey, (current) => ({
2448
+ ...current,
2449
+ status: "done",
2450
+ phase: "validating",
2451
+ blockedReason: undefined,
2452
+ taskCounts: apply.progress,
2453
+ currentTask: undefined,
2454
+ latestSummary: `All tasks for ${current.changeName} are complete. Use \`/clawspec archive\` when you are ready.`,
2455
+ }));
2456
+ }
2457
+
2458
+ if (
2459
+ project.taskCounts?.total !== apply.progress.total
2460
+ || project.taskCounts?.complete !== apply.progress.complete
2461
+ || project.taskCounts?.remaining !== apply.progress.remaining
2462
+ || project.currentTask !== nextCurrentTask
2463
+ ) {
2464
+ return await this.stateStore.updateProject(channelKey, (current) => ({
2465
+ ...current,
2466
+ taskCounts: apply.progress,
2467
+ currentTask: nextCurrentTask,
2468
+ }));
2469
+ }
2470
+
2471
+ return project;
2472
+ }
2473
+
2474
+ private async bindProjectSession(
2475
+ channelKey: string,
2476
+ project: ProjectState,
2477
+ sessionKey?: string,
2478
+ ): Promise<ProjectState> {
2479
+ if (!sessionKey || project.boundSessionKey === sessionKey) {
2480
+ return project;
2481
+ }
2482
+
2483
+ return await this.stateStore.updateProject(channelKey, (current) => ({
2484
+ ...current,
2485
+ boundSessionKey: sessionKey,
2486
+ }));
2487
+ }
2488
+
2489
+ private async readMeaningfulExecutionSummary(repoStatePaths: RepoStatePaths): Promise<string | undefined> {
2490
+ const latestSummary = ((await tryReadUtf8(repoStatePaths.latestSummaryFile)) ?? "")
2491
+ .trim();
2492
+ if (isMeaningfulExecutionSummary(latestSummary)) {
2493
+ return latestSummary;
2494
+ }
2495
+
2496
+ const progressLines = ((await tryReadUtf8(repoStatePaths.progressFile)) ?? "")
2497
+ .split(/\r?\n/)
2498
+ .map((line) => line.trim())
2499
+ .filter((line) => line.length > 0)
2500
+ .reverse();
2501
+ const progressSummary = progressLines.find((line) =>
2502
+ line.startsWith("- Blocked")
2503
+ || line.startsWith("- blocked")
2504
+ || line.startsWith("- summary:")
2505
+ );
2506
+ if (!progressSummary) {
2507
+ return undefined;
2508
+ }
2509
+
2510
+ return progressSummary
2511
+ .replace(/^- summary:\s*/i, "")
2512
+ .replace(/^- blocked(?: at [^:]+)?:\s*/i, "")
2513
+ .trim();
2514
+ }
2515
+
2516
+ private async clearChangeRuntimeFiles(repoStatePaths: RepoStatePaths): Promise<void> {
2517
+ await removeIfExists(repoStatePaths.executionControlFile);
2518
+ await removeIfExists(repoStatePaths.executionResultFile);
2519
+ await removeIfExists(repoStatePaths.workerProgressFile);
2520
+ await removeIfExists(repoStatePaths.planningJournalFile);
2521
+ await removeIfExists(repoStatePaths.planningJournalSnapshotFile);
2522
+ await removeIfExists(repoStatePaths.rollbackManifestFile);
2523
+ }
2524
+
2525
+ private async resetRunSupportFiles(repoStatePaths: RepoStatePaths, latestSummary: string): Promise<void> {
2526
+ await ensureDir(repoStatePaths.root);
2527
+ await removeIfExists(repoStatePaths.executionControlFile);
2528
+ await removeIfExists(repoStatePaths.executionResultFile);
2529
+ await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
2530
+ await writeUtf8(repoStatePaths.workerProgressFile, "");
2531
+ await writeUtf8(repoStatePaths.changedFilesFile, "# Changed Files\n");
2532
+ await writeUtf8(repoStatePaths.decisionLogFile, "# Decision Log\n");
2533
+ await writeUtf8(repoStatePaths.latestSummaryFile, `${latestSummary}\n`);
2534
+ }
2535
+
2536
+ private async finalizeCancellation(project: ProjectState, result?: ExecutionResult | null): Promise<void> {
2537
+ if (!project.repoPath || !project.changeName) {
2538
+ return;
2539
+ }
2540
+
2541
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
2542
+ const rollbackStore = new RollbackStore(project.repoPath, this.archiveDirName, project.changeName);
2543
+ await rollbackStore.restoreTouchedFiles().catch(() => undefined);
2544
+ await removeIfExists(getChangeDir(project.repoPath, project.changeName));
2545
+ await rollbackStore.clear().catch(() => undefined);
2546
+ await this.clearChangeRuntimeFiles(repoStatePaths);
2547
+ await this.resetRunSupportFiles(repoStatePaths, `Cancelled change ${project.changeName}.`);
2548
+
2549
+ const timestamp = result?.timestamp ?? new Date().toISOString();
2550
+ const lastExecution = result ?? {
2551
+ version: 1 as const,
2552
+ changeName: project.changeName,
2553
+ mode: project.execution?.mode ?? "continue",
2554
+ status: "cancelled" as const,
2555
+ timestamp,
2556
+ summary: `Cancelled change ${project.changeName}.`,
2557
+ progressMade: false,
2558
+ changedFiles: [],
2559
+ notes: [],
2560
+ taskCounts: project.taskCounts,
2561
+ remainingTasks: project.taskCounts?.remaining,
2562
+ };
2563
+
2564
+ await this.stateStore.updateProject(project.channelKey, (current) => ({
2565
+ ...current,
2566
+ status: "idle",
2567
+ phase: "cancelling",
2568
+ changeName: undefined,
2569
+ changeDir: undefined,
2570
+ description: undefined,
2571
+ currentTask: undefined,
2572
+ taskCounts: undefined,
2573
+ pauseRequested: false,
2574
+ cancelRequested: false,
2575
+ blockedReason: undefined,
2576
+ latestSummary: `Cancelled change ${project.changeName}.`,
2577
+ execution: undefined,
2578
+ lastExecution,
2579
+ lastExecutionAt: timestamp,
2580
+ planningJournal: {
2581
+ dirty: false,
2582
+ entryCount: 0,
2583
+ },
2584
+ rollback: undefined,
2585
+ boundSessionKey: current.boundSessionKey ?? project.boundSessionKey,
2586
+ }));
2587
+ }
2588
+
2589
+ private async ensureProjectSupportFiles(project: ProjectState): Promise<void> {
2590
+ if (!project.repoPath) {
2591
+ return;
2592
+ }
2593
+ const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
2594
+ await ensureDir(repoStatePaths.root);
2595
+ if (!(await pathExists(repoStatePaths.progressFile))) {
2596
+ await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
2597
+ }
2598
+ if (!(await pathExists(repoStatePaths.changedFilesFile))) {
2599
+ await writeUtf8(repoStatePaths.changedFilesFile, "# Changed Files\n");
2600
+ }
2601
+ if (!(await pathExists(repoStatePaths.decisionLogFile))) {
2602
+ await writeUtf8(repoStatePaths.decisionLogFile, "# Decision Log\n");
2603
+ }
2604
+ if (!(await pathExists(repoStatePaths.latestSummaryFile))) {
2605
+ await writeUtf8(repoStatePaths.latestSummaryFile, "No summary yet.\n");
2606
+ }
2607
+ if (!(await pathExists(repoStatePaths.planningJournalFile))) {
2608
+ await writeUtf8(repoStatePaths.planningJournalFile, "");
2609
+ }
2610
+ }
2611
+
2612
+ private async buildWorkspaceText(project: ProjectState, note?: string): Promise<string> {
2613
+ const workspacePath = project.workspacePath ?? await this.workspaceStore.getCurrentWorkspace();
2614
+ const workspaces = await this.workspaceStore.list();
2615
+ const catalog = await this.buildWorkspaceCatalog(workspacePath);
2616
+
2617
+ const lines = [
2618
+ heading("Workspace"),
2619
+ "",
2620
+ note ?? "",
2621
+ note ? "" : "",
2622
+ `Current workspace: \`${workspacePath}\``,
2623
+ project.projectName ? `Active project: \`${project.projectName}\`` : "Active project: _none_",
2624
+ project.changeName ? `Active change: \`${project.changeName}\` (${project.status})` : "Active change: _none_",
2625
+ "",
2626
+ "Known workspaces:",
2627
+ ...workspaces.map((entry) => `- ${samePath(entry.path, workspacePath) ? "* " : ""}\`${entry.path}\``),
2628
+ "",
2629
+ "Projects in workspace:",
2630
+ ...(catalog.length > 0
2631
+ ? catalog.map((entry) => `- \`${entry.label}\`${samePath(entry.repoPath, project.repoPath) ? " (active)" : ""}`)
2632
+ : ["- _none yet_"]),
2633
+ "",
2634
+ "Use `/clawspec use <project-name>` to select or create a project in this workspace.",
2635
+ ];
2636
+
2637
+ return lines.filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n");
2638
+ }
2639
+
2640
+ private async buildWorkspaceCatalog(workspacePath: string): Promise<ProjectCatalogEntry[]> {
2641
+ const dirs = await listDirectories(workspacePath);
2642
+ return dirs.map((dirName) => ({
2643
+ label: dirName,
2644
+ repoPath: path.join(workspacePath, dirName),
2645
+ source: "workspace",
2646
+ }));
2647
+ }
2648
+
2649
+ private resolveWorkspaceProjectPath(workspacePath: string, input: string): string {
2650
+ const resolved = resolveUserPath(input, workspacePath);
2651
+ const relative = path.relative(workspacePath, resolved);
2652
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
2653
+ throw new Error("`/clawspec use` only accepts a project path inside the current workspace.");
2654
+ }
2655
+ return resolved;
2656
+ }
2657
+
2658
+ private async renderStatus(
2659
+ project: ProjectState,
2660
+ note?: string,
2661
+ commandOutputs: OpenSpecCommandResult[] = [],
2662
+ applyResult?: OpenSpecApplyInstructionsResponse,
2663
+ ): Promise<string> {
2664
+ const taskCounts = applyResult?.progress ?? await this.loadTaskCounts(project);
2665
+ const executionStatus = project.execution
2666
+ ? `${project.execution.state} (${project.execution.mode})`
2667
+ : project.status === "planning"
2668
+ ? "visible-chat"
2669
+ : "idle";
2670
+ const workerAgent = project.execution?.workerAgentId ?? project.workerAgentId ?? this.defaultWorkerAgentId;
2671
+ const showLastExecution = !project.execution;
2672
+ const latestExecutionSummary = showLastExecution && project.lastExecution
2673
+ ? formatExecutionSummary(project.lastExecution)
2674
+ : undefined;
2675
+ const nextStepHint = project.status === "planning"
2676
+ ? `Planning sync is in progress for \`${project.changeName}\`. Let the current chat turn finish, then check status again.`
2677
+ : !isProjectContextAttached(project)
2678
+ ? "Chat context is detached from ClawSpec. Use `/clawspec attach` or `cs-attach` when you want ordinary chat to re-enter project mode."
2679
+ : requiresPlanningSync(project)
2680
+ ? buildPlanningRequiredMessage(project)
2681
+ : project.status === "blocked"
2682
+ ? ((project.phase === "proposal" || project.phase === "planning_sync")
2683
+ ? buildPlanningBlockedMessage(project)
2684
+ : project.blockedReason ?? `Change \`${project.changeName}\` is blocked. Review the latest status, then continue once the blocker is resolved.`)
2685
+ : (!isFinishedStatus(project.status) && project.changeName && !hasBlockingExecution(project))
2686
+ ? `Change \`${project.changeName}\` is ready for implementation. Use \`cs-work\` or \`/clawspec continue\` when you want to resume.`
2687
+ : undefined;
2688
+
2689
+ const lines = [
2690
+ heading("Project Status"),
2691
+ "",
2692
+ note ?? "",
2693
+ note ? "" : "",
2694
+ `Workspace: \`${project.workspacePath ?? "_unset_"}\``,
2695
+ `Project: ${project.projectName ? `\`${project.projectName}\`` : "_unset_"}`,
2696
+ `Repo path: ${project.repoPath ? `\`${project.repoPath}\`` : "_unset_"}`,
2697
+ `Change: ${project.changeName ? `\`${project.changeName}\`` : "_none_"}`,
2698
+ `Context: \`${isProjectContextAttached(project) ? "attached" : "detached"}\``,
2699
+ `Worker agent: \`${workerAgent}\``,
2700
+ `Lifecycle: \`${project.status}\``,
2701
+ `Phase: \`${project.phase}\``,
2702
+ `Execution: \`${executionStatus}\``,
2703
+ formatProjectTaskCounts(project, taskCounts),
2704
+ `Planning journal: ${project.planningJournal?.dirty ? "dirty" : "clean"} (${project.planningJournal?.entryCount ?? 0} entries)`,
2705
+ nextStepHint ? `Next step: ${nextStepHint}` : "",
2706
+ project.latestSummary ? `Latest summary: ${project.latestSummary}` : "Latest summary: _none_",
2707
+ project.blockedReason ? `Blocked reason: ${project.blockedReason}` : "",
2708
+ project.execution?.state === "armed"
2709
+ ? "Execution is queued in the background. Watch for progress updates here or use `/clawspec pause`."
2710
+ : "",
2711
+ latestExecutionSummary ? "" : "",
2712
+ latestExecutionSummary ? "Last execution:" : "",
2713
+ latestExecutionSummary ?? "",
2714
+ commandOutputs.length > 0 ? "" : "",
2715
+ formatCommandOutputSection(commandOutputs),
2716
+ ];
2717
+
2718
+ return lines.filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n");
2719
+ }
2720
+ }
2721
+
2722
+ function describeWorkerTransportMode(project: ProjectState): string {
2723
+ const execution = project.execution;
2724
+ if (!execution) {
2725
+ return "idle";
2726
+ }
2727
+ if (execution.state === "armed" && (execution.restartCount ?? 0) > 0) {
2728
+ return "restart-pending";
2729
+ }
2730
+ if (execution.state === "armed") {
2731
+ return "queued";
2732
+ }
2733
+ if (
2734
+ execution.state === "running"
2735
+ && typeof project.latestSummary === "string"
2736
+ && /monitoring the running .*worker/i.test(project.latestSummary)
2737
+ ) {
2738
+ return "adopted-monitoring";
2739
+ }
2740
+ if (execution.state === "running") {
2741
+ return "monitoring";
2742
+ }
2743
+ return execution.state;
2744
+ }
2745
+
2746
+ function describeWorkerRuntimeState(project: ProjectState, runtimeStatus?: AcpWorkerStatus): string {
2747
+ if (!project.execution?.sessionKey) {
2748
+ return "no-session";
2749
+ }
2750
+ if (!runtimeStatus) {
2751
+ return "unknown";
2752
+ }
2753
+
2754
+ const detailState = typeof runtimeStatus.details?.status === "string"
2755
+ ? runtimeStatus.details.status.trim().toLowerCase()
2756
+ : "";
2757
+ const summary = runtimeStatus.summary.trim().toLowerCase();
2758
+
2759
+ if (detailState === "alive" || detailState === "running") {
2760
+ return "alive";
2761
+ }
2762
+ if (detailState === "dead" && summary.includes("no-session")) {
2763
+ return "no-session";
2764
+ }
2765
+ if (detailState === "dead") {
2766
+ return "dead";
2767
+ }
2768
+ if (summary.includes("status=alive") || summary.includes("status=running")) {
2769
+ return "alive";
2770
+ }
2771
+ if (summary.includes("no-session")) {
2772
+ return "no-session";
2773
+ }
2774
+ if (summary.includes("status=dead")) {
2775
+ return "dead";
2776
+ }
2777
+ return "unknown";
2778
+ }
2779
+
2780
+ function describeExecutionStartupWait(execution: ProjectState["execution"]): string | undefined {
2781
+ if (!execution?.connectedAt || execution.firstProgressAt) {
2782
+ return undefined;
2783
+ }
2784
+ const connectedAt = Date.parse(execution.connectedAt);
2785
+ if (Number.isNaN(connectedAt)) {
2786
+ return undefined;
2787
+ }
2788
+ const elapsedMs = Math.max(0, Date.now() - connectedAt);
2789
+ const seconds = Math.max(1, Math.round(elapsedMs / 1000));
2790
+ if (seconds < 60) {
2791
+ return `${seconds}s`;
2792
+ }
2793
+ const minutes = Math.floor(seconds / 60);
2794
+ const remainder = seconds % 60;
2795
+ return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
2796
+ }
2797
+
2798
+ function extractLatestMessageTextByRole(
2799
+ messages: unknown[],
2800
+ role: "user" | "assistant",
2801
+ ): string | undefined {
2802
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
2803
+ const candidate = unwrapMessageEnvelope(messages[index]);
2804
+ if (!candidate) {
2805
+ continue;
2806
+ }
2807
+
2808
+ const candidateRole = typeof candidate.role === "string"
2809
+ ? candidate.role.trim().toLowerCase()
2810
+ : "";
2811
+ if (candidateRole !== role) {
2812
+ continue;
2813
+ }
2814
+
2815
+ const text = extractMessageText(candidate).trim();
2816
+ if (text) {
2817
+ return text;
2818
+ }
2819
+ }
2820
+
2821
+ return undefined;
2822
+ }
2823
+
2824
+ function unwrapMessageEnvelope(value: unknown): Record<string, unknown> | undefined {
2825
+ if (!isRecord(value)) {
2826
+ return undefined;
2827
+ }
2828
+
2829
+ if (isRecord(value.message) && typeof value.message.role === "string") {
2830
+ return value.message;
2831
+ }
2832
+
2833
+ return value;
2834
+ }
2835
+
2836
+ function extractMessageText(value: unknown, depth = 0): string {
2837
+ if (depth > 6 || value == null) {
2838
+ return "";
2839
+ }
2840
+
2841
+ if (typeof value === "string") {
2842
+ return value;
2843
+ }
2844
+
2845
+ if (Array.isArray(value)) {
2846
+ return value
2847
+ .map((entry) => extractMessageText(entry, depth + 1).trim())
2848
+ .filter((entry) => entry.length > 0)
2849
+ .join("\n")
2850
+ .trim();
2851
+ }
2852
+
2853
+ if (!isRecord(value)) {
2854
+ return "";
2855
+ }
2856
+
2857
+ if (typeof value.text === "string" && value.text.trim().length > 0) {
2858
+ return value.text;
2859
+ }
2860
+
2861
+ if (typeof value.value === "string" && value.value.trim().length > 0) {
2862
+ return value.value;
2863
+ }
2864
+
2865
+ const nested = [
2866
+ value.content,
2867
+ value.parts,
2868
+ value.value,
2869
+ value.message,
2870
+ ];
2871
+
2872
+ for (const entry of nested) {
2873
+ const text = extractMessageText(entry, depth + 1).trim();
2874
+ if (text) {
2875
+ return text;
2876
+ }
2877
+ }
2878
+
2879
+ return "";
2880
+ }
2881
+
2882
+ function isPassiveAssistantPlanningMessage(text: string): boolean {
2883
+ const normalized = text.trim();
2884
+ if (!normalized) {
2885
+ return true;
2886
+ }
2887
+
2888
+ const collapsed = normalized.replace(/\s+/g, " ").trim();
2889
+ if (/^[▶✓⚠↻ℹ]/u.test(collapsed)) {
2890
+ return true;
2891
+ }
2892
+ if (/^[^[]+\[[#-]{4,}\]\s+\d+\/\d+\b/u.test(collapsed)) {
2893
+ return true;
2894
+ }
2895
+
2896
+ const lower = collapsed.toLowerCase();
2897
+ if ([
2898
+ "project started",
2899
+ "project selected",
2900
+ "proposal ready",
2901
+ "planning ready",
2902
+ "worker status",
2903
+ "project status",
2904
+ "worker agent",
2905
+ "context attached",
2906
+ "context detached",
2907
+ "change cancelled",
2908
+ "change archived",
2909
+ "cancellation requested",
2910
+ "workspace switched",
2911
+ "no new planning notes",
2912
+ "planning preparation failed",
2913
+ "execution preparation failed",
2914
+ ].some((prefix) => lower.startsWith(prefix))) {
2915
+ return true;
2916
+ }
2917
+
2918
+ if (
2919
+ lower.startsWith("execution started for ")
2920
+ || lower.startsWith("working on task ")
2921
+ || lower.startsWith("completed task ")
2922
+ || lower.startsWith("blocked: ")
2923
+ || lower.startsWith("all tasks complete")
2924
+ || lower.startsWith("planning sync for ")
2925
+ || lower.startsWith("background execution for ")
2926
+ ) {
2927
+ return true;
2928
+ }
2929
+
2930
+ if (
2931
+ /^select a project\b/i.test(collapsed)
2932
+ || /^no active clawspec project is bound to this chat\b/i.test(collapsed)
2933
+ || /^change `[^`]+` is (?:active|waiting|ready|blocked|not apply-ready)\b/i.test(collapsed)
2934
+ || /^normal chat is (?:already )?detached\b/i.test(collapsed)
2935
+ || /^ordinary chat .* detached\b/i.test(collapsed)
2936
+ ) {
2937
+ return true;
2938
+ }
2939
+
2940
+ const lines = normalized
2941
+ .split(/\r?\n/)
2942
+ .map((line) => line.trim())
2943
+ .filter((line) => line.length > 0);
2944
+ return lines.length > 0 && lines.every(isWorkflowControlLine);
2945
+ }
2946
+
2947
+ function isWorkflowControlLine(line: string): boolean {
2948
+ const lower = line.trim().toLowerCase();
2949
+ if (!lower) {
2950
+ return true;
2951
+ }
2952
+
2953
+ return lower.startsWith("next:")
2954
+ || lower.startsWith("next step:")
2955
+ || lower.startsWith("use `cs-")
2956
+ || lower.startsWith("run `cs-")
2957
+ || lower.startsWith("say `cs-")
2958
+ || lower.startsWith("use `/clawspec")
2959
+ || lower.startsWith("run `/clawspec")
2960
+ || lower.startsWith("say `/clawspec")
2961
+ || lower.startsWith("continue describing requirements")
2962
+ || lower.startsWith("planning now runs in the visible chat")
2963
+ || lower.startsWith("when the requirement is clear enough")
2964
+ || lower.startsWith("`cs-work` becomes available")
2965
+ || lower.startsWith("command fallback:")
2966
+ || lower === "`cs-work` is not available yet.";
2967
+ }
2968
+
2969
+ function isRecord(value: unknown): value is Record<string, unknown> {
2970
+ return typeof value === "object" && value !== null;
2971
+ }