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,527 @@
1
+ import path from "node:path";
2
+ import {
3
+ ensureDir,
4
+ pathExists,
5
+ readJsonFile,
6
+ removeIfExists,
7
+ writeJsonFile,
8
+ } from "../utils/fs.ts";
9
+ import {
10
+ getActiveProjectMapPath,
11
+ getGlobalProjectStatePath,
12
+ getPluginStateRoot,
13
+ getRepoStatePaths,
14
+ } from "../utils/paths.ts";
15
+ import { createProjectId } from "../utils/slug.ts";
16
+ import { withFileLock } from "./locks.ts";
17
+ import type { ActiveProjectMap, ProjectPhase, ProjectState, ProjectStatus } from "../types.ts";
18
+
19
+ export class ActiveProjectConflictError extends Error {
20
+ readonly project: ProjectState;
21
+
22
+ constructor(project: ProjectState) {
23
+ super(`Channel already has an active project: ${project.projectId}`);
24
+ this.project = project;
25
+ }
26
+ }
27
+
28
+ export class ProjectStateStore {
29
+ readonly stateDir: string;
30
+ readonly archiveDirName: string;
31
+ private initPromise: Promise<void> | undefined;
32
+
33
+ constructor(stateDir: string, archiveDirName: string) {
34
+ this.stateDir = stateDir;
35
+ this.archiveDirName = archiveDirName;
36
+ }
37
+
38
+ async initialize(): Promise<void> {
39
+ this.initPromise ??= this.doInitialize();
40
+ return this.initPromise;
41
+ }
42
+
43
+ private async doInitialize(): Promise<void> {
44
+ await ensureDir(getPluginStateRoot(this.stateDir));
45
+ await ensureDir(path.dirname(getActiveProjectMapPath(this.stateDir)));
46
+ }
47
+
48
+ async getActiveProject(channelKey: string): Promise<ProjectState | null> {
49
+ return this.withChannelLock(channelKey, async () => {
50
+ const record = await this.readActiveRecord(channelKey);
51
+ if (!record) {
52
+ return null;
53
+ }
54
+
55
+ if (!(await pathExists(record.statePath))) {
56
+ await this.deleteActiveRecordIfMatches(channelKey, record.statePath);
57
+ return null;
58
+ }
59
+
60
+ const project = await readJsonFile<ProjectState | null>(record.statePath, null);
61
+ if (!project) {
62
+ await this.deleteActiveRecordIfMatches(channelKey, record.statePath);
63
+ return null;
64
+ }
65
+ return project;
66
+ });
67
+ }
68
+
69
+ async listActiveProjects(): Promise<ProjectState[]> {
70
+ return this.withActiveMapLock(async () => {
71
+ const mapping = await this.readActiveMap();
72
+ const projects: ProjectState[] = [];
73
+ let dirty = false;
74
+
75
+ for (const [channelKey, record] of Object.entries(mapping.channels)) {
76
+ if (!(await pathExists(record.statePath))) {
77
+ delete mapping.channels[channelKey];
78
+ dirty = true;
79
+ continue;
80
+ }
81
+
82
+ const project = await readJsonFile<ProjectState | null>(record.statePath, null);
83
+ if (!project) {
84
+ delete mapping.channels[channelKey];
85
+ dirty = true;
86
+ continue;
87
+ }
88
+
89
+ projects.push(project);
90
+ }
91
+
92
+ if (dirty) {
93
+ await this.writeActiveMap(mapping);
94
+ }
95
+
96
+ return projects;
97
+ });
98
+ }
99
+
100
+ async findActiveProjectForMessage(params: {
101
+ channel?: string;
102
+ channelId: string;
103
+ accountId?: string;
104
+ conversationId?: string;
105
+ }): Promise<{ channelKey: string; project: ProjectState } | null> {
106
+ return this.withActiveMapLock(async () => {
107
+ const mapping = await this.readActiveMap();
108
+ const accountId = params.accountId ?? "default";
109
+ let dirty = false;
110
+ const exactCandidates: Array<{
111
+ channelKey: string;
112
+ project: ProjectState;
113
+ parsed: ReturnType<typeof parseChannelKey>;
114
+ }> = [];
115
+ const relaxedCandidates: Array<{
116
+ channelKey: string;
117
+ project: ProjectState;
118
+ parsed: ReturnType<typeof parseChannelKey>;
119
+ }> = [];
120
+
121
+ for (const [channelKey, record] of Object.entries(mapping.channels)) {
122
+ const parsed = parseChannelKey(channelKey);
123
+ if (parsed.channelId !== params.channelId) {
124
+ continue;
125
+ }
126
+
127
+ if (!(await pathExists(record.statePath))) {
128
+ delete mapping.channels[channelKey];
129
+ dirty = true;
130
+ continue;
131
+ }
132
+
133
+ const project = await readJsonFile<ProjectState | null>(record.statePath, null);
134
+ if (!project) {
135
+ delete mapping.channels[channelKey];
136
+ dirty = true;
137
+ continue;
138
+ }
139
+
140
+ const candidate = { channelKey, project, parsed };
141
+ if (parsed.accountId === accountId) {
142
+ exactCandidates.push(candidate);
143
+ } else {
144
+ relaxedCandidates.push(candidate);
145
+ }
146
+ }
147
+
148
+ if (dirty) {
149
+ await this.writeActiveMap(mapping);
150
+ }
151
+
152
+ const candidates = exactCandidates.length > 0 ? exactCandidates : relaxedCandidates;
153
+ if (candidates.length === 0) {
154
+ return null;
155
+ }
156
+
157
+ const byProjectId = new Map<
158
+ string,
159
+ {
160
+ project: ProjectState;
161
+ aliases: Array<{
162
+ channelKey: string;
163
+ parsed: ReturnType<typeof parseChannelKey>;
164
+ }>;
165
+ }
166
+ >();
167
+
168
+ for (const candidate of candidates) {
169
+ const existing = byProjectId.get(candidate.project.projectId);
170
+ if (existing) {
171
+ existing.aliases.push({
172
+ channelKey: candidate.channelKey,
173
+ parsed: candidate.parsed,
174
+ });
175
+ continue;
176
+ }
177
+
178
+ byProjectId.set(candidate.project.projectId, {
179
+ project: candidate.project,
180
+ aliases: [
181
+ {
182
+ channelKey: candidate.channelKey,
183
+ parsed: candidate.parsed,
184
+ },
185
+ ],
186
+ });
187
+ }
188
+
189
+ if (byProjectId.size === 1) {
190
+ const match = Array.from(byProjectId.values())[0];
191
+ return {
192
+ channelKey: resolveCanonicalChannelKey(mapping, match.project, match.aliases[0]?.channelKey),
193
+ project: match.project,
194
+ };
195
+ }
196
+
197
+ const ranked = candidates
198
+ .map((candidate) => ({
199
+ ...candidate,
200
+ score: scoreMessageCandidate(candidate.parsed, params),
201
+ }))
202
+ .filter((candidate) => candidate.score > 0)
203
+ .sort((left, right) => right.score - left.score);
204
+
205
+ const best = ranked[0];
206
+ if (!best) {
207
+ return null;
208
+ }
209
+
210
+ return {
211
+ channelKey: resolveCanonicalChannelKey(mapping, best.project, best.channelKey),
212
+ project: best.project,
213
+ };
214
+ });
215
+ }
216
+
217
+ async createProject(channelKey: string): Promise<ProjectState> {
218
+ return this.withChannelLock(channelKey, async () => {
219
+ const existingRecord = await this.readActiveRecord(channelKey);
220
+ if (existingRecord && (await pathExists(existingRecord.statePath))) {
221
+ const existingProject = await readJsonFile<ProjectState | null>(existingRecord.statePath, null);
222
+ if (existingProject && existingProject.status !== "archived") {
223
+ throw new ActiveProjectConflictError(existingProject);
224
+ }
225
+ }
226
+
227
+ const now = new Date().toISOString();
228
+ const projectId = createProjectId();
229
+ const storagePath = getGlobalProjectStatePath(this.stateDir, projectId);
230
+ const project: ProjectState = {
231
+ version: 1,
232
+ projectId,
233
+ channelKey,
234
+ storagePath,
235
+ status: "idle",
236
+ phase: "init",
237
+ createdAt: now,
238
+ updatedAt: now,
239
+ pauseRequested: false,
240
+ consecutiveNoProgressTurns: 0,
241
+ };
242
+
243
+ return this.persistProjectUnlocked(project, { keepActive: true });
244
+ });
245
+ }
246
+
247
+ async updateProject(
248
+ channelKey: string,
249
+ updater: (current: ProjectState) => ProjectState | Promise<ProjectState>,
250
+ ): Promise<ProjectState> {
251
+ return this.withChannelLock(channelKey, async () => {
252
+ const record = await this.readActiveRecord(channelKey);
253
+ if (!record) {
254
+ throw new Error("No active project for this channel.");
255
+ }
256
+ const current = await readJsonFile<ProjectState | null>(record.statePath, null);
257
+ if (!current) {
258
+ await this.deleteActiveRecordIfMatches(channelKey, record.statePath);
259
+ throw new Error("The active project state file could not be loaded.");
260
+ }
261
+ const next = await updater(current);
262
+ next.updatedAt = new Date().toISOString();
263
+ return this.persistProjectUnlocked(next, { keepActive: next.status !== "archived" });
264
+ });
265
+ }
266
+
267
+ async clearActiveProject(channelKey: string): Promise<void> {
268
+ await this.withChannelLock(channelKey, async () => {
269
+ await this.deleteActiveRecordIfMatches(channelKey);
270
+ });
271
+ }
272
+
273
+ async setRepoPath(
274
+ channelKey: string,
275
+ repoPath: string,
276
+ rememberedProjectName?: string,
277
+ ): Promise<ProjectState> {
278
+ return this.updateProject(channelKey, (project) => ({
279
+ ...project,
280
+ repoPath,
281
+ rememberedProjectName,
282
+ openspecRoot: path.join(repoPath, "openspec"),
283
+ status: "collecting_description",
284
+ phase: "init",
285
+ }));
286
+ }
287
+
288
+ async setDescription(
289
+ channelKey: string,
290
+ description: string,
291
+ projectTitle: string,
292
+ changeName: string,
293
+ ): Promise<ProjectState> {
294
+ return this.updateProject(channelKey, (project) => ({
295
+ ...project,
296
+ description,
297
+ projectTitle,
298
+ changeName,
299
+ changeDir: project.repoPath ? path.join(project.repoPath, "openspec", "changes", changeName) : undefined,
300
+ status: "bootstrapping",
301
+ phase: "init",
302
+ }));
303
+ }
304
+
305
+ async setLifecycle(
306
+ channelKey: string,
307
+ patch: {
308
+ status?: ProjectStatus;
309
+ phase?: ProjectPhase;
310
+ pauseRequested?: boolean;
311
+ blockedReason?: string | undefined;
312
+ latestSummary?: string | undefined;
313
+ currentTask?: string | undefined;
314
+ },
315
+ ): Promise<ProjectState> {
316
+ return this.updateProject(channelKey, (project) => ({
317
+ ...project,
318
+ ...patch,
319
+ }));
320
+ }
321
+
322
+ async moveActiveProjectChannel(sourceChannelKey: string, targetChannelKey: string): Promise<ProjectState | null> {
323
+ if (!sourceChannelKey || !targetChannelKey || sourceChannelKey === targetChannelKey) {
324
+ return this.getActiveProject(targetChannelKey);
325
+ }
326
+
327
+ return this.withActiveMapLock(async () => {
328
+ const mapping = await this.readActiveMap();
329
+ const sourceRecord = mapping.channels[sourceChannelKey];
330
+ if (!sourceRecord) {
331
+ return null;
332
+ }
333
+
334
+ if (!(await pathExists(sourceRecord.statePath))) {
335
+ delete mapping.channels[sourceChannelKey];
336
+ await this.writeActiveMap(mapping);
337
+ return null;
338
+ }
339
+
340
+ const sourceProject = await readJsonFile<ProjectState | null>(sourceRecord.statePath, null);
341
+ if (!sourceProject) {
342
+ delete mapping.channels[sourceChannelKey];
343
+ await this.writeActiveMap(mapping);
344
+ return null;
345
+ }
346
+
347
+ const targetRecord = mapping.channels[targetChannelKey];
348
+ if (targetRecord && await pathExists(targetRecord.statePath)) {
349
+ const targetProject = await readJsonFile<ProjectState | null>(targetRecord.statePath, null);
350
+ if (targetProject && targetProject.projectId !== sourceProject.projectId && targetProject.status !== "archived") {
351
+ return targetProject;
352
+ }
353
+ }
354
+
355
+ const nextProject: ProjectState = {
356
+ ...sourceProject,
357
+ channelKey: targetChannelKey,
358
+ updatedAt: new Date().toISOString(),
359
+ };
360
+
361
+ await writeJsonFile(sourceRecord.statePath, nextProject);
362
+ mapping.channels[targetChannelKey] = {
363
+ projectId: nextProject.projectId,
364
+ statePath: sourceRecord.statePath,
365
+ };
366
+ delete mapping.channels[sourceChannelKey];
367
+ await this.writeActiveMap(mapping);
368
+ return nextProject;
369
+ });
370
+ }
371
+
372
+ private async withChannelLock<T>(channelKey: string, action: () => Promise<T>): Promise<T> {
373
+ const lockPath = path.join(getPluginStateRoot(this.stateDir), "locks", `${sanitizeChannelKey(channelKey)}.lock`);
374
+ return withFileLock(lockPath, action);
375
+ }
376
+
377
+ private async withActiveMapLock<T>(action: () => Promise<T>): Promise<T> {
378
+ const lockPath = path.join(getPluginStateRoot(this.stateDir), "locks", "active-projects.lock");
379
+ return withFileLock(lockPath, action);
380
+ }
381
+
382
+ private async readActiveMap(): Promise<ActiveProjectMap> {
383
+ return readJsonFile<ActiveProjectMap>(getActiveProjectMapPath(this.stateDir), {
384
+ version: 1,
385
+ channels: {},
386
+ });
387
+ }
388
+
389
+ private async writeActiveMap(mapping: ActiveProjectMap): Promise<void> {
390
+ await writeJsonFile(getActiveProjectMapPath(this.stateDir), mapping);
391
+ }
392
+
393
+ private async persistProjectUnlocked(
394
+ project: ProjectState,
395
+ options: { keepActive: boolean },
396
+ ): Promise<ProjectState> {
397
+ const targetPath = project.repoPath
398
+ ? getRepoStatePaths(project.repoPath, this.archiveDirName).stateFile
399
+ : project.storagePath || getGlobalProjectStatePath(this.stateDir, project.projectId);
400
+
401
+ const nextProject: ProjectState = {
402
+ ...project,
403
+ storagePath: targetPath,
404
+ };
405
+
406
+ await ensureDir(path.dirname(targetPath));
407
+ await writeJsonFile(targetPath, nextProject);
408
+
409
+ if (project.storagePath && project.storagePath !== targetPath) {
410
+ await removeIfExists(project.storagePath);
411
+ }
412
+
413
+ await this.withActiveMapLock(async () => {
414
+ const mapping = await this.readActiveMap();
415
+ if (options.keepActive) {
416
+ mapping.channels[nextProject.channelKey] = {
417
+ projectId: nextProject.projectId,
418
+ statePath: targetPath,
419
+ };
420
+ } else {
421
+ delete mapping.channels[nextProject.channelKey];
422
+ }
423
+ await this.writeActiveMap(mapping);
424
+ });
425
+ return nextProject;
426
+ }
427
+
428
+ private async readActiveRecord(channelKey: string): Promise<ActiveProjectMap["channels"][string] | undefined> {
429
+ return this.withActiveMapLock(async () => {
430
+ const mapping = await this.readActiveMap();
431
+ return mapping.channels[channelKey];
432
+ });
433
+ }
434
+
435
+ private async deleteActiveRecordIfMatches(channelKey: string, expectedStatePath?: string): Promise<void> {
436
+ await this.withActiveMapLock(async () => {
437
+ const mapping = await this.readActiveMap();
438
+ const record = mapping.channels[channelKey];
439
+ if (!record) {
440
+ return;
441
+ }
442
+ if (expectedStatePath && record.statePath !== expectedStatePath) {
443
+ return;
444
+ }
445
+ delete mapping.channels[channelKey];
446
+ await this.writeActiveMap(mapping);
447
+ });
448
+ }
449
+ }
450
+
451
+ function sanitizeChannelKey(channelKey: string): string {
452
+ return channelKey.replace(/[^a-zA-Z0-9_-]+/g, "-");
453
+ }
454
+
455
+ function parseChannelKey(channelKey: string): {
456
+ channel: string;
457
+ channelId: string;
458
+ accountId: string;
459
+ conversationId: string;
460
+ } {
461
+ const [channel = "", channelId = "", accountId = "", ...conversationParts] = channelKey.split(":");
462
+ return {
463
+ channel,
464
+ channelId,
465
+ accountId,
466
+ conversationId: conversationParts.join(":"),
467
+ };
468
+ }
469
+
470
+ function resolveCanonicalChannelKey(
471
+ mapping: ActiveProjectMap,
472
+ project: ProjectState,
473
+ fallbackChannelKey?: string,
474
+ ): string {
475
+ if (mapping.channels[project.channelKey]) {
476
+ return project.channelKey;
477
+ }
478
+ return fallbackChannelKey ?? project.channelKey;
479
+ }
480
+
481
+ function scoreMessageCandidate(
482
+ candidate: {
483
+ channel: string;
484
+ channelId: string;
485
+ accountId: string;
486
+ conversationId: string;
487
+ },
488
+ params: {
489
+ channel?: string;
490
+ channelId: string;
491
+ accountId?: string;
492
+ conversationId?: string;
493
+ },
494
+ ): number {
495
+ let score = 0;
496
+
497
+ if (params.channel && candidate.channel === params.channel) {
498
+ score += 8;
499
+ }
500
+
501
+ const requestedConversation = normalizeConversationId(params.conversationId);
502
+ const candidateConversation = normalizeConversationId(candidate.conversationId);
503
+ if (requestedConversation && candidateConversation === requestedConversation) {
504
+ score += 16;
505
+ }
506
+
507
+ if (requestedConversation && matchesSlashConversation(candidateConversation, requestedConversation)) {
508
+ score += 12;
509
+ }
510
+
511
+ if (!requestedConversation && candidateConversation === "main") {
512
+ score += 4;
513
+ }
514
+
515
+ return score;
516
+ }
517
+
518
+ function normalizeConversationId(value?: string): string {
519
+ return value?.trim() ?? "";
520
+ }
521
+
522
+ function matchesSlashConversation(left: string, right: string): boolean {
523
+ if (!left || !right) {
524
+ return false;
525
+ }
526
+ return left === `slash:${right}` || right === `slash:${left}`;
527
+ }