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
package/src/types.ts ADDED
@@ -0,0 +1,301 @@
1
+ export const PROJECT_STATUSES = [
2
+ "idle",
3
+ "collecting_path",
4
+ "collecting_description",
5
+ "bootstrapping",
6
+ "planning",
7
+ "ready",
8
+ "armed",
9
+ "running",
10
+ "paused",
11
+ "blocked",
12
+ "done",
13
+ "archived",
14
+ "cancelled",
15
+ "error",
16
+ ] as const;
17
+
18
+ export const PROJECT_PHASES = [
19
+ "init",
20
+ "proposal",
21
+ "specs",
22
+ "design",
23
+ "tasks",
24
+ "planning_sync",
25
+ "implementing",
26
+ "validating",
27
+ "archiving",
28
+ "cancelling",
29
+ ] as const;
30
+
31
+ export const EXECUTION_STATES = ["armed", "running"] as const;
32
+ export const EXECUTION_RESULT_STATUSES = ["running", "paused", "blocked", "done", "cancelled"] as const;
33
+ export const EXECUTION_MODES = ["apply", "continue"] as const;
34
+ export const EXECUTION_ACTIONS = ["plan", "work"] as const;
35
+ export const EXECUTION_STARTUP_PHASES = ["queued", "starting", "connected", "waiting_for_update", "active"] as const;
36
+
37
+ export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
38
+ export type ProjectPhase = (typeof PROJECT_PHASES)[number];
39
+ export type ExecutionState = (typeof EXECUTION_STATES)[number];
40
+ export type ExecutionResultStatus = (typeof EXECUTION_RESULT_STATUSES)[number];
41
+ export type ExecutionMode = (typeof EXECUTION_MODES)[number];
42
+ export type ExecutionAction = (typeof EXECUTION_ACTIONS)[number];
43
+ export type ExecutionStartupPhase = (typeof EXECUTION_STARTUP_PHASES)[number];
44
+
45
+ export type TaskCountSummary = {
46
+ total: number;
47
+ complete: number;
48
+ remaining: number;
49
+ };
50
+
51
+ export type RememberedProject = {
52
+ name: string;
53
+ normalizedName: string;
54
+ repoPath: string;
55
+ createdAt: string;
56
+ updatedAt: string;
57
+ };
58
+
59
+ export type ProjectMemoryFile = {
60
+ version: 1;
61
+ projects: RememberedProject[];
62
+ };
63
+
64
+ export type WorkspaceRecord = {
65
+ path: string;
66
+ lastUsedAt: string;
67
+ };
68
+
69
+ export type WorkspaceStateFile = {
70
+ version: 1;
71
+ currentWorkspace: string;
72
+ currentWorkspaceByChannel?: Record<string, string>;
73
+ workspaces: WorkspaceRecord[];
74
+ };
75
+
76
+ export type ActiveProjectMap = {
77
+ version: 1;
78
+ channels: Record<
79
+ string,
80
+ {
81
+ projectId: string;
82
+ statePath: string;
83
+ }
84
+ >;
85
+ };
86
+
87
+ export type ProjectExecutionState = {
88
+ mode: ExecutionMode;
89
+ action: ExecutionAction;
90
+ state: ExecutionState;
91
+ startupPhase?: ExecutionStartupPhase;
92
+ workerAgentId?: string;
93
+ workerSlot?: string;
94
+ armedAt: string;
95
+ startedAt?: string;
96
+ connectedAt?: string;
97
+ firstProgressAt?: string;
98
+ lastStartupNoticeAt?: string;
99
+ sessionKey?: string;
100
+ backendId?: string;
101
+ triggerPrompt?: string;
102
+ lastTriggerAt?: string;
103
+ currentArtifact?: string;
104
+ currentTaskId?: string;
105
+ lastHeartbeatAt?: string;
106
+ progressOffset?: number;
107
+ restartCount?: number;
108
+ lastRestartAt?: string;
109
+ lastFailure?: string;
110
+ };
111
+
112
+ export type ExecutionControlFile = {
113
+ version: 1;
114
+ changeName: string;
115
+ mode: ExecutionMode;
116
+ state: ExecutionState;
117
+ armedAt: string;
118
+ startedAt?: string;
119
+ sessionKey?: string;
120
+ pauseRequested: boolean;
121
+ cancelRequested: boolean;
122
+ };
123
+
124
+ export type ExecutionResult = {
125
+ version: 1;
126
+ changeName: string;
127
+ mode: ExecutionMode;
128
+ status: ExecutionResultStatus;
129
+ timestamp: string;
130
+ summary: string;
131
+ progressMade: boolean;
132
+ completedTask?: string;
133
+ currentArtifact?: string;
134
+ changedFiles: string[];
135
+ notes: string[];
136
+ blocker?: string;
137
+ taskCounts?: TaskCountSummary;
138
+ remainingTasks?: number;
139
+ };
140
+
141
+ export type ProjectState = {
142
+ version: 1;
143
+ projectId: string;
144
+ channelKey: string;
145
+ storagePath: string;
146
+ status: ProjectStatus;
147
+ phase: ProjectPhase;
148
+ createdAt: string;
149
+ updatedAt: string;
150
+ workspacePath?: string;
151
+ repoPath?: string;
152
+ projectName?: string;
153
+ rememberedProjectName?: string;
154
+ projectTitle?: string;
155
+ workerAgentId?: string;
156
+ description?: string;
157
+ changeName?: string;
158
+ openspecRoot?: string;
159
+ changeDir?: string;
160
+ pauseRequested: boolean;
161
+ cancelRequested?: boolean;
162
+ blockedReason?: string;
163
+ currentTask?: string;
164
+ taskCounts?: TaskCountSummary;
165
+ latestSummary?: string;
166
+ lastNotificationKey?: string;
167
+ execution?: ProjectExecutionState;
168
+ lastExecutionAt?: string;
169
+ lastExecution?: ExecutionResult;
170
+ planningJournal?: PlanningJournalState;
171
+ rollback?: RollbackState;
172
+ archivePath?: string;
173
+ boundSessionKey?: string;
174
+ contextMode?: "attached" | "detached";
175
+ };
176
+
177
+ export type PlanningJournalEntry = {
178
+ timestamp: string;
179
+ changeName: string;
180
+ role: "user" | "assistant";
181
+ text: string;
182
+ };
183
+
184
+ export type PlanningJournalState = {
185
+ dirty: boolean;
186
+ entryCount: number;
187
+ lastEntryAt?: string;
188
+ lastSyncedAt?: string;
189
+ };
190
+
191
+ export type PlanningJournalSnapshot = {
192
+ version: 1;
193
+ changeName: string;
194
+ syncedAt: string;
195
+ entryCount: number;
196
+ lastEntryAt?: string;
197
+ contentHash: string;
198
+ };
199
+
200
+ export type RollbackTrackedFileKind = "modified" | "created" | "deleted";
201
+
202
+ export type RollbackTrackedFile = {
203
+ path: string;
204
+ kind: RollbackTrackedFileKind;
205
+ };
206
+
207
+ export type RollbackManifest = {
208
+ version: 1;
209
+ changeName: string;
210
+ baselineRoot: string;
211
+ createdAt: string;
212
+ updatedAt: string;
213
+ files: RollbackTrackedFile[];
214
+ cancelledAt?: string;
215
+ archivedAt?: string;
216
+ };
217
+
218
+ export type RollbackState = {
219
+ baselineRoot?: string;
220
+ manifestPath?: string;
221
+ snapshotReady?: boolean;
222
+ touchedFileCount?: number;
223
+ lastUpdatedAt?: string;
224
+ };
225
+
226
+ export type ParsedTask = {
227
+ raw: string;
228
+ lineNumber: number;
229
+ checked: boolean;
230
+ taskId: string;
231
+ description: string;
232
+ };
233
+
234
+ export type ParsedTaskList = {
235
+ tasks: ParsedTask[];
236
+ counts: TaskCountSummary;
237
+ };
238
+
239
+ export type OpenSpecArtifactRecord = {
240
+ id: string;
241
+ outputPath: string;
242
+ status: string;
243
+ };
244
+
245
+ export type OpenSpecStatusResponse = {
246
+ changeName: string;
247
+ schemaName: string;
248
+ isComplete: boolean;
249
+ applyRequires: string[];
250
+ artifacts: OpenSpecArtifactRecord[];
251
+ };
252
+
253
+ export type OpenSpecArtifactDependency = {
254
+ id: string;
255
+ done: boolean;
256
+ path: string;
257
+ description: string;
258
+ };
259
+
260
+ export type OpenSpecInstructionsResponse = {
261
+ changeName: string;
262
+ artifactId: string;
263
+ schemaName: string;
264
+ changeDir: string;
265
+ outputPath: string;
266
+ description: string;
267
+ instruction: string;
268
+ template: string;
269
+ dependencies: OpenSpecArtifactDependency[];
270
+ unlocks: string[];
271
+ };
272
+
273
+ export type OpenSpecApplyInstructionsResponse = {
274
+ changeName: string;
275
+ changeDir: string;
276
+ schemaName: string;
277
+ contextFiles: Record<string, string>;
278
+ progress: TaskCountSummary;
279
+ tasks: Array<{
280
+ id: string;
281
+ description: string;
282
+ done: boolean;
283
+ }>;
284
+ state: "ready" | "blocked" | "all_done";
285
+ instruction: string;
286
+ };
287
+
288
+ export type OpenSpecValidationResponse = {
289
+ valid?: boolean;
290
+ errors?: unknown[];
291
+ warnings?: unknown[];
292
+ };
293
+
294
+ export type OpenSpecCommandResult<T = undefined> = {
295
+ command: string;
296
+ cwd: string;
297
+ stdout: string;
298
+ stderr: string;
299
+ durationMs: number;
300
+ parsed?: T;
301
+ };
@@ -0,0 +1,88 @@
1
+ export function tokenizeArgs(input: string): string[] {
2
+ const tokens: string[] = [];
3
+ let current = "";
4
+ let quote: "'" | '"' | null = null;
5
+ let escaping = false;
6
+
7
+ for (const char of input) {
8
+ if (escaping) {
9
+ current += char;
10
+ escaping = false;
11
+ continue;
12
+ }
13
+
14
+ if (char === "\\") {
15
+ escaping = true;
16
+ continue;
17
+ }
18
+
19
+ if (quote) {
20
+ if (char === quote) {
21
+ quote = null;
22
+ } else {
23
+ current += char;
24
+ }
25
+ continue;
26
+ }
27
+
28
+ if (char === "'" || char === "\"") {
29
+ quote = char;
30
+ continue;
31
+ }
32
+
33
+ if (/\s/.test(char)) {
34
+ if (current.length > 0) {
35
+ tokens.push(current);
36
+ current = "";
37
+ }
38
+ continue;
39
+ }
40
+
41
+ current += char;
42
+ }
43
+
44
+ if (quote) {
45
+ throw new Error("Unterminated quoted argument.");
46
+ }
47
+
48
+ if (escaping) {
49
+ current += "\\";
50
+ }
51
+
52
+ if (current.length > 0) {
53
+ tokens.push(current);
54
+ }
55
+
56
+ return tokens;
57
+ }
58
+
59
+ export function splitSubcommand(rawArgs: string | undefined): {
60
+ subcommand: string;
61
+ rest: string;
62
+ } {
63
+ const trimmed = (rawArgs ?? "").trim();
64
+ if (trimmed.length === 0) {
65
+ return { subcommand: "", rest: "" };
66
+ }
67
+
68
+ const firstWhitespace = trimmed.search(/\s/);
69
+ if (firstWhitespace === -1) {
70
+ return { subcommand: trimmed.toLowerCase(), rest: "" };
71
+ }
72
+
73
+ return {
74
+ subcommand: trimmed.slice(0, firstWhitespace).toLowerCase(),
75
+ rest: trimmed.slice(firstWhitespace + 1).trim(),
76
+ };
77
+ }
78
+
79
+ export function removeFlag(tokens: string[], flag: string): {
80
+ tokens: string[];
81
+ present: boolean;
82
+ } {
83
+ const filtered = tokens.filter((token) => token !== flag);
84
+ return {
85
+ tokens: filtered,
86
+ present: filtered.length !== tokens.length,
87
+ };
88
+ }
@@ -0,0 +1,66 @@
1
+ import type { PluginCommandContext } from "openclaw/plugin-sdk";
2
+
3
+ export type ParsedChannelKey = {
4
+ channel: string;
5
+ channelId: string;
6
+ accountId: string;
7
+ conversationId: string;
8
+ };
9
+
10
+ export function buildChannelKeyFromCommand(ctx: PluginCommandContext): string {
11
+ const conversationKey = ctx.messageThreadId?.toString() ?? "main";
12
+ return buildChannelKey({
13
+ channel: ctx.channel,
14
+ channelId: ctx.channelId ?? ctx.channel,
15
+ accountId: ctx.accountId ?? "default",
16
+ conversationId: conversationKey,
17
+ });
18
+ }
19
+
20
+ export function buildLegacyChannelKeyFromCommand(ctx: PluginCommandContext): string {
21
+ const conversationKey = ctx.messageThreadId?.toString() ?? ctx.to ?? ctx.from ?? "main";
22
+ return buildChannelKey({
23
+ channel: ctx.channel,
24
+ channelId: ctx.channelId ?? ctx.channel,
25
+ accountId: ctx.accountId ?? "default",
26
+ conversationId: conversationKey,
27
+ });
28
+ }
29
+
30
+ export function buildChannelKeyFromMessage(params: {
31
+ channel?: string;
32
+ channelId: string;
33
+ accountId?: string;
34
+ conversationId?: string;
35
+ }): string {
36
+ return buildChannelKey({
37
+ channel: params.channel ?? params.channelId,
38
+ channelId: params.channelId,
39
+ accountId: params.accountId ?? "default",
40
+ conversationId: params.conversationId ?? "main",
41
+ });
42
+ }
43
+
44
+ export function parseChannelKey(channelKey: string): ParsedChannelKey {
45
+ const [channel = "", channelId = "", accountId = "", ...conversationParts] = channelKey.split(":");
46
+ return {
47
+ channel,
48
+ channelId,
49
+ accountId,
50
+ conversationId: conversationParts.join(":"),
51
+ };
52
+ }
53
+
54
+ function buildChannelKey(params: {
55
+ channel: string;
56
+ channelId: string;
57
+ accountId: string;
58
+ conversationId: string;
59
+ }): string {
60
+ return [
61
+ params.channel,
62
+ params.channelId,
63
+ params.accountId,
64
+ params.conversationId,
65
+ ].join(":");
66
+ }
@@ -0,0 +1,31 @@
1
+ export function prependPathEntries(
2
+ env: NodeJS.ProcessEnv | undefined,
3
+ entries: string[],
4
+ ): NodeJS.ProcessEnv {
5
+ const nextEnv: NodeJS.ProcessEnv = { ...(env ?? process.env) };
6
+ const cleanEntries = entries
7
+ .map((entry) => entry.trim())
8
+ .filter((entry) => entry.length > 0);
9
+ if (cleanEntries.length === 0) {
10
+ return nextEnv;
11
+ }
12
+
13
+ const pathKey = findPathKey(nextEnv);
14
+ const current = nextEnv[pathKey] ?? "";
15
+ const parts = current
16
+ .split(process.platform === "win32" ? ";" : ":")
17
+ .map((part) => part.trim())
18
+ .filter((part) => part.length > 0);
19
+
20
+ const merged = [...cleanEntries, ...parts.filter((part) => !cleanEntries.includes(part))];
21
+ nextEnv[pathKey] = merged.join(process.platform === "win32" ? ";" : ":");
22
+ return nextEnv;
23
+ }
24
+
25
+ function findPathKey(env: NodeJS.ProcessEnv): string {
26
+ const existing = Object.keys(env).find((key) => key.toLowerCase() === "path");
27
+ if (existing) {
28
+ return existing;
29
+ }
30
+ return process.platform === "win32" ? "Path" : "PATH";
31
+ }
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { appendFile, copyFile, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ const ATOMIC_WRITE_RETRYABLE_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOENT"]);
7
+ const ATOMIC_WRITE_RETRY_DELAYS_MS = [20, 60, 120, 250];
8
+
9
+ export async function ensureDir(dirPath: string): Promise<void> {
10
+ await mkdir(dirPath, { recursive: true });
11
+ }
12
+
13
+ export async function pathExists(targetPath: string): Promise<boolean> {
14
+ try {
15
+ await stat(targetPath);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export async function directoryExists(targetPath: string): Promise<boolean> {
23
+ try {
24
+ return (await stat(targetPath)).isDirectory();
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export async function readUtf8(filePath: string): Promise<string> {
31
+ return readFile(filePath, "utf8");
32
+ }
33
+
34
+ export async function tryReadUtf8(filePath: string): Promise<string | undefined> {
35
+ try {
36
+ return await readUtf8(filePath);
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ export async function writeUtf8(filePath: string, content: string): Promise<void> {
43
+ await ensureDir(path.dirname(filePath));
44
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
45
+ await writeFile(tempPath, content, "utf8");
46
+ try {
47
+ await renameWithRetry(tempPath, filePath);
48
+ } catch (error) {
49
+ const code = getErrorCode(error);
50
+ if (!code || !ATOMIC_WRITE_RETRYABLE_CODES.has(code)) {
51
+ await cleanupTempFile(tempPath);
52
+ throw error;
53
+ }
54
+
55
+ await ensureDir(path.dirname(filePath));
56
+ await writeFile(filePath, content, "utf8");
57
+ await cleanupTempFile(tempPath);
58
+ }
59
+ }
60
+
61
+ export async function appendUtf8(filePath: string, content: string): Promise<void> {
62
+ await ensureDir(path.dirname(filePath));
63
+ await appendFile(filePath, content, "utf8");
64
+ }
65
+
66
+ export async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
67
+ try {
68
+ const raw = await readUtf8(filePath);
69
+ return JSON.parse(raw) as T;
70
+ } catch {
71
+ return fallback;
72
+ }
73
+ }
74
+
75
+ export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
76
+ await writeUtf8(filePath, `${JSON.stringify(value, null, 2)}\n`);
77
+ }
78
+
79
+ export async function removeIfExists(targetPath: string): Promise<void> {
80
+ try {
81
+ await rm(targetPath, { force: true, recursive: true });
82
+ } catch {
83
+ return;
84
+ }
85
+ }
86
+
87
+ export async function copyIfExists(sourcePath: string, destinationPath: string): Promise<void> {
88
+ if (!(await pathExists(sourcePath))) {
89
+ return;
90
+ }
91
+ await ensureDir(path.dirname(destinationPath));
92
+ await copyFile(sourcePath, destinationPath, constants.COPYFILE_FICLONE_FORCE).catch(async () => {
93
+ await copyFile(sourcePath, destinationPath);
94
+ });
95
+ }
96
+
97
+ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
98
+ if (!(await pathExists(rootDir))) {
99
+ return [];
100
+ }
101
+ const results: string[] = [];
102
+ const entries = await readdir(rootDir, { withFileTypes: true });
103
+ for (const entry of entries) {
104
+ const fullPath = path.join(rootDir, entry.name);
105
+ if (entry.isDirectory()) {
106
+ results.push(...(await listFilesRecursive(fullPath)));
107
+ } else if (entry.isFile()) {
108
+ results.push(fullPath);
109
+ }
110
+ }
111
+ return results;
112
+ }
113
+
114
+ export async function listDirectoryFiles(dirPath: string): Promise<string[]> {
115
+ if (!(await pathExists(dirPath))) {
116
+ return [];
117
+ }
118
+ const entries = await readdir(dirPath, { withFileTypes: true });
119
+ return entries.filter((entry) => entry.isFile()).map((entry) => path.join(dirPath, entry.name));
120
+ }
121
+
122
+ async function renameWithRetry(sourcePath: string, destinationPath: string): Promise<void> {
123
+ let lastError: unknown;
124
+ for (const delayMs of [0, ...ATOMIC_WRITE_RETRY_DELAYS_MS]) {
125
+ if (delayMs > 0) {
126
+ await delay(delayMs);
127
+ }
128
+
129
+ try {
130
+ await rename(sourcePath, destinationPath);
131
+ return;
132
+ } catch (error) {
133
+ lastError = error;
134
+ const code = getErrorCode(error);
135
+ if (!code || !ATOMIC_WRITE_RETRYABLE_CODES.has(code)) {
136
+ throw error;
137
+ }
138
+ }
139
+ }
140
+
141
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "rename failed"));
142
+ }
143
+
144
+ async function cleanupTempFile(filePath: string): Promise<void> {
145
+ try {
146
+ await unlink(filePath);
147
+ } catch {
148
+ return;
149
+ }
150
+ }
151
+
152
+ function getErrorCode(error: unknown): string | undefined {
153
+ if (!error || typeof error !== "object") {
154
+ return undefined;
155
+ }
156
+ return typeof (error as { code?: unknown }).code === "string"
157
+ ? String((error as { code?: unknown }).code)
158
+ : undefined;
159
+ }
160
+
161
+ function delay(ms: number): Promise<void> {
162
+ return new Promise((resolve) => setTimeout(resolve, ms));
163
+ }
164
+
165
+ export async function listDirectories(rootDir: string): Promise<string[]> {
166
+ if (!(await directoryExists(rootDir))) {
167
+ return [];
168
+ }
169
+ const entries = await readdir(rootDir, { withFileTypes: true });
170
+ return entries
171
+ .filter((entry) => entry.isDirectory())
172
+ .map((entry) => entry.name)
173
+ .sort((left, right) => left.localeCompare(right));
174
+ }
175
+
176
+ export function stripAnsi(input: string): string {
177
+ return input.replace(
178
+ // eslint-disable-next-line no-control-regex
179
+ /\u001b\[[0-9;]*m/g,
180
+ "",
181
+ );
182
+ }
183
+
184
+ export function normalizeSlashes(input: string): string {
185
+ return input.replace(/\\/g, "/");
186
+ }
187
+
188
+ export function toPosixRelative(baseDir: string, targetPath: string): string {
189
+ const relativePath = path.relative(baseDir, targetPath);
190
+ return normalizeSlashes(relativePath || ".");
191
+ }
192
+
193
+ export async function resolveSimpleGlob(baseDir: string, pattern: string): Promise<string[]> {
194
+ if (!pattern.includes("*")) {
195
+ const exact = path.join(baseDir, pattern);
196
+ return (await pathExists(exact)) ? [exact] : [];
197
+ }
198
+
199
+ const normalized = normalizeSlashes(pattern);
200
+ if (normalized.endsWith("**/*.md")) {
201
+ const prefix = normalized.slice(0, normalized.indexOf("**/*.md"));
202
+ const root = path.join(baseDir, prefix);
203
+ const files = await listFilesRecursive(root);
204
+ return files.filter((filePath) => filePath.toLowerCase().endsWith(".md"));
205
+ }
206
+
207
+ const files = await listFilesRecursive(baseDir);
208
+ const needle = normalized.replace(/\*\*/g, "").replace(/\*/g, "");
209
+ return files.filter((filePath) => normalizeSlashes(path.relative(baseDir, filePath)).includes(needle));
210
+ }
211
+
212
+ export function takeLineExcerpt(text: string, maxLines = 12): string {
213
+ const lines = stripAnsi(text)
214
+ .split(/\r?\n/)
215
+ .map((line) => line.trimEnd())
216
+ .filter((line) => line.length > 0);
217
+ return lines.slice(0, maxLines).join("\n");
218
+ }