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,118 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ appendUtf8,
4
+ pathExists,
5
+ readJsonFile,
6
+ readUtf8,
7
+ removeIfExists,
8
+ writeJsonFile,
9
+ } from "../utils/fs.ts";
10
+ import type {
11
+ PlanningJournalEntry,
12
+ PlanningJournalSnapshot,
13
+ } from "../types.ts";
14
+
15
+ type PlanningJournalDigest = {
16
+ changeName: string;
17
+ entryCount: number;
18
+ lastEntryAt?: string;
19
+ contentHash: string;
20
+ };
21
+
22
+ export class PlanningJournalStore {
23
+ readonly filePath: string;
24
+
25
+ constructor(filePath: string) {
26
+ this.filePath = filePath;
27
+ }
28
+
29
+ async append(entry: PlanningJournalEntry): Promise<void> {
30
+ await appendUtf8(this.filePath, `${JSON.stringify(entry)}\n`);
31
+ }
32
+
33
+ async list(changeName?: string): Promise<PlanningJournalEntry[]> {
34
+ if (!(await pathExists(this.filePath))) {
35
+ return [];
36
+ }
37
+ const raw = await readUtf8(this.filePath);
38
+ const entries = raw
39
+ .split(/\r?\n/)
40
+ .map((line) => line.trim())
41
+ .filter((line) => line.length > 0)
42
+ .flatMap((line) => {
43
+ try {
44
+ return [JSON.parse(line) as PlanningJournalEntry];
45
+ } catch {
46
+ return [];
47
+ }
48
+ });
49
+ return changeName ? entries.filter((entry) => entry.changeName === changeName) : entries;
50
+ }
51
+
52
+ async clear(): Promise<void> {
53
+ await removeIfExists(this.filePath);
54
+ }
55
+
56
+ async readSnapshot(snapshotPath: string): Promise<PlanningJournalSnapshot | null> {
57
+ return await readJsonFile<PlanningJournalSnapshot | null>(snapshotPath, null);
58
+ }
59
+
60
+ async writeSnapshot(snapshotPath: string, changeName: string, syncedAt = new Date().toISOString()): Promise<PlanningJournalSnapshot> {
61
+ const digest = await this.digest(changeName);
62
+ const snapshot: PlanningJournalSnapshot = {
63
+ version: 1,
64
+ changeName,
65
+ syncedAt,
66
+ entryCount: digest.entryCount,
67
+ lastEntryAt: digest.lastEntryAt,
68
+ contentHash: digest.contentHash,
69
+ };
70
+ await writeJsonFile(snapshotPath, snapshot);
71
+ return snapshot;
72
+ }
73
+
74
+ async clearSnapshot(snapshotPath: string): Promise<void> {
75
+ await removeIfExists(snapshotPath);
76
+ }
77
+
78
+ async hasUnsyncedChanges(
79
+ changeName: string,
80
+ snapshotPath: string,
81
+ fallbackLastSyncedAt?: string,
82
+ ): Promise<boolean> {
83
+ const digest = await this.digest(changeName);
84
+ const snapshot = await this.readSnapshot(snapshotPath);
85
+ if (!snapshot) {
86
+ if (
87
+ fallbackLastSyncedAt
88
+ && digest.lastEntryAt
89
+ && Date.parse(digest.lastEntryAt) <= Date.parse(fallbackLastSyncedAt)
90
+ ) {
91
+ return false;
92
+ }
93
+ return digest.entryCount > 0;
94
+ }
95
+ return snapshot.changeName !== changeName
96
+ || snapshot.entryCount !== digest.entryCount
97
+ || snapshot.lastEntryAt !== digest.lastEntryAt
98
+ || snapshot.contentHash !== digest.contentHash;
99
+ }
100
+
101
+ async digest(changeName: string): Promise<PlanningJournalDigest> {
102
+ const entries = await this.list(changeName);
103
+ const normalized = entries.map((entry) => ({
104
+ timestamp: entry.timestamp,
105
+ changeName: entry.changeName,
106
+ role: entry.role,
107
+ text: entry.text,
108
+ }));
109
+ const serialized = JSON.stringify(normalized);
110
+ const contentHash = createHash("sha256").update(serialized).digest("hex");
111
+ return {
112
+ changeName,
113
+ entryCount: normalized.length,
114
+ lastEntryAt: normalized[normalized.length - 1]?.timestamp,
115
+ contentHash,
116
+ };
117
+ }
118
+ }
@@ -0,0 +1,173 @@
1
+ import path from "node:path";
2
+ import type { RollbackManifest, RollbackTrackedFile } from "../types.ts";
3
+ import {
4
+ copyIfExists,
5
+ ensureDir,
6
+ listFilesRecursive,
7
+ normalizeSlashes,
8
+ pathExists,
9
+ readJsonFile,
10
+ removeIfExists,
11
+ toPosixRelative,
12
+ writeJsonFile,
13
+ } from "../utils/fs.ts";
14
+ import { getChangeBaselineRoot, getChangeSnapshotRoot, getRepoStatePaths } from "../utils/paths.ts";
15
+
16
+ export class RollbackStore {
17
+ readonly repoPath: string;
18
+ readonly archiveDirName: string;
19
+ readonly changeName: string;
20
+ readonly manifestPath: string;
21
+ readonly snapshotRoot: string;
22
+ readonly baselineRoot: string;
23
+
24
+ constructor(repoPath: string, archiveDirName: string, changeName: string) {
25
+ this.repoPath = repoPath;
26
+ this.archiveDirName = archiveDirName;
27
+ this.changeName = changeName;
28
+ const repoStatePaths = getRepoStatePaths(repoPath, archiveDirName);
29
+ this.manifestPath = repoStatePaths.rollbackManifestFile;
30
+ this.snapshotRoot = getChangeSnapshotRoot(repoPath, archiveDirName, changeName);
31
+ this.baselineRoot = getChangeBaselineRoot(repoPath, archiveDirName, changeName);
32
+ }
33
+
34
+ async initializeBaseline(): Promise<RollbackManifest> {
35
+ const existing = await this.readManifest();
36
+ if (existing && existing.changeName === this.changeName && await pathExists(this.baselineRoot)) {
37
+ return existing;
38
+ }
39
+
40
+ await removeIfExists(this.snapshotRoot);
41
+ await ensureDir(this.baselineRoot);
42
+
43
+ const repoFiles = await listFilesRecursive(this.repoPath);
44
+ for (const filePath of repoFiles) {
45
+ const relativePath = toPosixRelative(this.repoPath, filePath);
46
+ if (!shouldSnapshotPath(relativePath)) {
47
+ continue;
48
+ }
49
+ await copyIfExists(filePath, this.resolveBaselinePath(relativePath));
50
+ }
51
+
52
+ const now = new Date().toISOString();
53
+ const manifest: RollbackManifest = {
54
+ version: 1,
55
+ changeName: this.changeName,
56
+ baselineRoot: this.baselineRoot,
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ files: [],
60
+ };
61
+ await writeJsonFile(this.manifestPath, manifest);
62
+ return manifest;
63
+ }
64
+
65
+ async readManifest(): Promise<RollbackManifest | null> {
66
+ const manifest = await readJsonFile<RollbackManifest | null>(this.manifestPath, null);
67
+ if (!manifest || manifest.changeName !== this.changeName) {
68
+ return null;
69
+ }
70
+ return manifest;
71
+ }
72
+
73
+ async recordTouchedFiles(relativePaths: string[]): Promise<RollbackManifest> {
74
+ const manifest = await this.requireManifest();
75
+ const trackedByPath = new Map<string, RollbackTrackedFile>(
76
+ manifest.files.map((entry) => [entry.path, entry]),
77
+ );
78
+
79
+ for (const relativePath of relativePaths) {
80
+ const normalized = normalizeTrackedPath(relativePath);
81
+ if (!normalized || !shouldTrackPath(normalized)) {
82
+ continue;
83
+ }
84
+ trackedByPath.set(normalized, {
85
+ path: normalized,
86
+ kind: await this.detectKind(normalized),
87
+ });
88
+ }
89
+
90
+ const next: RollbackManifest = {
91
+ ...manifest,
92
+ updatedAt: new Date().toISOString(),
93
+ files: Array.from(trackedByPath.values()).sort((left, right) => left.path.localeCompare(right.path)),
94
+ };
95
+ await writeJsonFile(this.manifestPath, next);
96
+ return next;
97
+ }
98
+
99
+ async restoreTouchedFiles(): Promise<RollbackManifest> {
100
+ const manifest = await this.requireManifest();
101
+
102
+ for (const entry of manifest.files) {
103
+ const livePath = this.resolveRepoPath(entry.path);
104
+ const baselinePath = this.resolveBaselinePath(entry.path);
105
+
106
+ if (entry.kind === "created") {
107
+ await removeIfExists(livePath);
108
+ continue;
109
+ }
110
+
111
+ if (await pathExists(baselinePath)) {
112
+ await copyIfExists(baselinePath, livePath);
113
+ continue;
114
+ }
115
+
116
+ await removeIfExists(livePath);
117
+ }
118
+
119
+ const next: RollbackManifest = {
120
+ ...manifest,
121
+ cancelledAt: new Date().toISOString(),
122
+ updatedAt: new Date().toISOString(),
123
+ };
124
+ await writeJsonFile(this.manifestPath, next);
125
+ return next;
126
+ }
127
+
128
+ async clear(): Promise<void> {
129
+ await removeIfExists(this.snapshotRoot);
130
+ await removeIfExists(this.manifestPath);
131
+ }
132
+
133
+ private async requireManifest(): Promise<RollbackManifest> {
134
+ const manifest = await this.readManifest();
135
+ if (!manifest) {
136
+ throw new Error(`Rollback manifest is missing for change \`${this.changeName}\`.`);
137
+ }
138
+ return manifest;
139
+ }
140
+
141
+ private async detectKind(relativePath: string): Promise<RollbackTrackedFile["kind"]> {
142
+ const baselineExists = await pathExists(this.resolveBaselinePath(relativePath));
143
+ const liveExists = await pathExists(this.resolveRepoPath(relativePath));
144
+ if (!baselineExists) {
145
+ return "created";
146
+ }
147
+ if (!liveExists) {
148
+ return "deleted";
149
+ }
150
+ return "modified";
151
+ }
152
+
153
+ private resolveRepoPath(relativePath: string): string {
154
+ return path.join(this.repoPath, ...relativePath.split("/"));
155
+ }
156
+
157
+ private resolveBaselinePath(relativePath: string): string {
158
+ return path.join(this.baselineRoot, ...relativePath.split("/"));
159
+ }
160
+ }
161
+
162
+ function normalizeTrackedPath(relativePath: string): string | undefined {
163
+ const normalized = normalizeSlashes(relativePath).replace(/^\.\//, "");
164
+ return normalized.length === 0 ? undefined : normalized;
165
+ }
166
+
167
+ function shouldSnapshotPath(relativePath: string): boolean {
168
+ return !relativePath.startsWith(".openclaw/clawspec/");
169
+ }
170
+
171
+ function shouldTrackPath(relativePath: string): boolean {
172
+ return shouldSnapshotPath(relativePath);
173
+ }
@@ -0,0 +1,133 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { open, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ensureDir, readUtf8, removeIfExists } from "../utils/fs.ts";
5
+
6
+ type FileLockOptions = {
7
+ retries?: number;
8
+ delayMs?: number;
9
+ staleMs?: number;
10
+ };
11
+
12
+ const activeLocks = new Map<string, Promise<void>>();
13
+ const DEFAULT_LOCK_RETRIES = 400;
14
+ const DEFAULT_LOCK_DELAY_MS = 25;
15
+ const DEFAULT_LOCK_STALE_MS = 60_000;
16
+
17
+ export async function withFileLock<T>(
18
+ lockPath: string,
19
+ action: () => Promise<T>,
20
+ options?: FileLockOptions,
21
+ ): Promise<T> {
22
+ await ensureDir(path.dirname(lockPath));
23
+ const previous = activeLocks.get(lockPath) ?? Promise.resolve();
24
+ let releaseLock: (() => void) | undefined;
25
+ const current = new Promise<void>((resolve) => {
26
+ releaseLock = resolve;
27
+ });
28
+ activeLocks.set(lockPath, previous.then(() => current));
29
+
30
+ await previous;
31
+
32
+ const token = randomUUID();
33
+ let handle: Awaited<ReturnType<typeof acquireLockHandle>> | undefined;
34
+
35
+ try {
36
+ handle = await acquireLockHandle(lockPath, token, options);
37
+ return await action();
38
+ } finally {
39
+ await handle?.close().catch(() => undefined);
40
+ if (handle) {
41
+ await releaseLockFile(lockPath, token);
42
+ }
43
+ releaseLock?.();
44
+ if (activeLocks.get(lockPath) === current) {
45
+ activeLocks.delete(lockPath);
46
+ }
47
+ }
48
+ }
49
+
50
+ async function acquireLockHandle(
51
+ lockPath: string,
52
+ token: string,
53
+ options?: FileLockOptions,
54
+ ) {
55
+ const retries = options?.retries ?? DEFAULT_LOCK_RETRIES;
56
+ const delayMs = options?.delayMs ?? DEFAULT_LOCK_DELAY_MS;
57
+ const staleMs = options?.staleMs ?? DEFAULT_LOCK_STALE_MS;
58
+
59
+ let attempt = 0;
60
+ while (attempt <= retries) {
61
+ try {
62
+ const handle = await open(lockPath, "wx");
63
+ await handle.writeFile(JSON.stringify({
64
+ token,
65
+ pid: process.pid,
66
+ acquiredAt: Date.now(),
67
+ }), "utf8");
68
+ return handle;
69
+ } catch (error) {
70
+ const code = getErrorCode(error);
71
+ if (code !== "EEXIST") {
72
+ throw error;
73
+ }
74
+
75
+ if (await isLockStale(lockPath, staleMs)) {
76
+ await removeIfExists(lockPath);
77
+ continue;
78
+ }
79
+
80
+ if (attempt === retries) {
81
+ throw new Error(`timed out waiting for lock ${lockPath}`);
82
+ }
83
+
84
+ attempt += 1;
85
+ await delay(delayMs);
86
+ }
87
+ }
88
+
89
+ throw new Error(`timed out waiting for lock ${lockPath}`);
90
+ }
91
+
92
+ async function isLockStale(lockPath: string, staleMs: number): Promise<boolean> {
93
+ try {
94
+ const metadata = JSON.parse(await readUtf8(lockPath)) as { acquiredAt?: unknown };
95
+ if (typeof metadata.acquiredAt === "number" && Number.isFinite(metadata.acquiredAt)) {
96
+ return (Date.now() - metadata.acquiredAt) > staleMs;
97
+ }
98
+ } catch {
99
+ // Fall through to stat-based detection.
100
+ }
101
+
102
+ try {
103
+ const details = await stat(lockPath);
104
+ return (Date.now() - details.mtimeMs) > staleMs;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ async function releaseLockFile(lockPath: string, token: string): Promise<void> {
111
+ try {
112
+ const metadata = JSON.parse(await readUtf8(lockPath)) as { token?: unknown };
113
+ if (metadata.token !== token) {
114
+ return;
115
+ }
116
+ } catch {
117
+ return;
118
+ }
119
+ await removeIfExists(lockPath);
120
+ }
121
+
122
+ function getErrorCode(error: unknown): string | undefined {
123
+ if (!error || typeof error !== "object") {
124
+ return undefined;
125
+ }
126
+ return typeof (error as { code?: unknown }).code === "string"
127
+ ? String((error as { code?: unknown }).code)
128
+ : undefined;
129
+ }
130
+
131
+ function delay(ms: number): Promise<void> {
132
+ return new Promise((resolve) => setTimeout(resolve, ms));
133
+ }