clawspec 1.0.19 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/README.zh-CN.md +6 -0
- package/package.json +1 -2
- package/src/bootstrap/state.ts +128 -0
- package/src/dependencies/acpx.ts +6 -0
- package/src/dependencies/openspec.ts +5 -0
- package/src/index.ts +125 -43
- package/src/watchers/manager.ts +69 -1
- package/test/acp-client.test.ts +0 -309
- package/test/acpx-dependency.test.ts +0 -133
- package/test/assistant-journal.test.ts +0 -203
- package/test/command-surface.test.ts +0 -24
- package/test/config.test.ts +0 -77
- package/test/detach-attach.test.ts +0 -98
- package/test/doctor.test.ts +0 -142
- package/test/file-lock.test.ts +0 -88
- package/test/fs-utils.test.ts +0 -22
- package/test/helpers/harness.ts +0 -305
- package/test/helpers.test.ts +0 -108
- package/test/keywords.test.ts +0 -92
- package/test/notifier.test.ts +0 -29
- package/test/openspec-dependency.test.ts +0 -68
- package/test/paths-utils.test.ts +0 -30
- package/test/pause-cancel.test.ts +0 -55
- package/test/planning-journal.test.ts +0 -155
- package/test/plugin-registration.test.ts +0 -35
- package/test/project-memory.test.ts +0 -42
- package/test/proposal.test.ts +0 -24
- package/test/queue-planning.test.ts +0 -322
- package/test/queue-work.test.ts +0 -220
- package/test/recovery.test.ts +0 -603
- package/test/service-archive.test.ts +0 -87
- package/test/shell-command.test.ts +0 -48
- package/test/state-store.test.ts +0 -74
- package/test/tasks-and-checkpoint.test.ts +0 -60
- package/test/use-project.test.ts +0 -67
- package/test/watcher-planning.test.ts +0 -533
- package/test/watcher-work.test.ts +0 -1771
- package/test/worker-command.test.ts +0 -66
- package/test/worker-io-helper.test.ts +0 -97
- package/test/worker-skills.test.ts +0 -12
package/test/helpers/harness.ts
DELETED
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
import os from "node:os";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { mkdtemp, mkdir } from "node:fs/promises";
|
|
4
|
-
import { PlanningJournalStore } from "../../src/planning/journal.ts";
|
|
5
|
-
import { parsePluginConfig } from "../../src/config.ts";
|
|
6
|
-
import { ProjectMemoryStore } from "../../src/memory/store.ts";
|
|
7
|
-
import { ClawSpecService } from "../../src/orchestrator/service.ts";
|
|
8
|
-
import { ProjectStateStore } from "../../src/state/store.ts";
|
|
9
|
-
import { writeUtf8 } from "../../src/utils/fs.ts";
|
|
10
|
-
import { getRepoStatePaths } from "../../src/utils/paths.ts";
|
|
11
|
-
import { WorkspaceStore } from "../../src/workspace/store.ts";
|
|
12
|
-
|
|
13
|
-
export type FakeWatcherManager = {
|
|
14
|
-
wakeCalls: string[];
|
|
15
|
-
interruptCalls: Array<{ channelKey: string; reason: string }>;
|
|
16
|
-
runtimeStatusCalls: string[];
|
|
17
|
-
runtimeStatus?: unknown;
|
|
18
|
-
wake: (channelKey: string) => Promise<void>;
|
|
19
|
-
interrupt: (channelKey: string, reason: string) => Promise<void>;
|
|
20
|
-
getWorkerRuntimeStatus: (channelKeyOrProject: string | { channelKey: string }) => Promise<unknown>;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export function createFakeWatcherManager(): FakeWatcherManager {
|
|
24
|
-
const wakeCalls: string[] = [];
|
|
25
|
-
const interruptCalls: Array<{ channelKey: string; reason: string }> = [];
|
|
26
|
-
const runtimeStatusCalls: string[] = [];
|
|
27
|
-
const manager: FakeWatcherManager = {
|
|
28
|
-
wakeCalls,
|
|
29
|
-
interruptCalls,
|
|
30
|
-
runtimeStatusCalls,
|
|
31
|
-
runtimeStatus: undefined,
|
|
32
|
-
wake: async (channelKey: string) => {
|
|
33
|
-
wakeCalls.push(channelKey);
|
|
34
|
-
},
|
|
35
|
-
interrupt: async (channelKey: string, reason: string) => {
|
|
36
|
-
interruptCalls.push({ channelKey, reason });
|
|
37
|
-
},
|
|
38
|
-
getWorkerRuntimeStatus: async (channelKeyOrProject: string | { channelKey: string }) => {
|
|
39
|
-
const channelKey = typeof channelKeyOrProject === "string"
|
|
40
|
-
? channelKeyOrProject
|
|
41
|
-
: channelKeyOrProject.channelKey;
|
|
42
|
-
runtimeStatusCalls.push(channelKey);
|
|
43
|
-
return manager.runtimeStatus;
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
return manager;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function createLogger() {
|
|
50
|
-
return {
|
|
51
|
-
info: () => undefined,
|
|
52
|
-
warn: () => undefined,
|
|
53
|
-
error: () => undefined,
|
|
54
|
-
debug: () => undefined,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function waitFor(
|
|
59
|
-
check: () => Promise<boolean>,
|
|
60
|
-
timeoutMs = 4_000,
|
|
61
|
-
pollIntervalMs = 250,
|
|
62
|
-
): Promise<void> {
|
|
63
|
-
const startedAt = Date.now();
|
|
64
|
-
while (Date.now() - startedAt < timeoutMs) {
|
|
65
|
-
if (await check()) {
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
69
|
-
}
|
|
70
|
-
throw new Error("Timed out waiting for test condition.");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export async function createServiceHarness(prefix: string): Promise<{
|
|
74
|
-
service: ClawSpecService;
|
|
75
|
-
stateStore: ProjectStateStore;
|
|
76
|
-
memoryStore: ProjectMemoryStore;
|
|
77
|
-
workspaceStore: WorkspaceStore;
|
|
78
|
-
watcherManager: FakeWatcherManager;
|
|
79
|
-
sentMessages: Array<{ to: string; text: string }>;
|
|
80
|
-
workspacePath: string;
|
|
81
|
-
repoPath: string;
|
|
82
|
-
changeDir: string;
|
|
83
|
-
openSpec: Record<string, any>;
|
|
84
|
-
}> {
|
|
85
|
-
const tempRoot = await mkdtemp(path.join(os.tmpdir(), prefix));
|
|
86
|
-
const workspacePath = path.join(tempRoot, "workspace");
|
|
87
|
-
const repoPath = path.join(workspacePath, "demo-app");
|
|
88
|
-
const changeDir = path.join(repoPath, "openspec", "changes", "demo-change");
|
|
89
|
-
await mkdir(workspacePath, { recursive: true });
|
|
90
|
-
|
|
91
|
-
const stateStore = new ProjectStateStore(tempRoot, "archives");
|
|
92
|
-
const memoryStore = new ProjectMemoryStore(path.join(tempRoot, "memory.json"));
|
|
93
|
-
const workspaceStore = new WorkspaceStore(path.join(tempRoot, "workspace-state.json"), workspacePath);
|
|
94
|
-
await stateStore.initialize();
|
|
95
|
-
await memoryStore.initialize();
|
|
96
|
-
await workspaceStore.initialize();
|
|
97
|
-
|
|
98
|
-
const openSpec = {
|
|
99
|
-
init: async (cwd: string) => {
|
|
100
|
-
await writeUtf8(path.join(cwd, "openspec", "config.yaml"), "schema: spec-driven\n");
|
|
101
|
-
return {
|
|
102
|
-
command: "openspec init --tools none .",
|
|
103
|
-
cwd,
|
|
104
|
-
stdout: "initialized",
|
|
105
|
-
stderr: "",
|
|
106
|
-
durationMs: 1,
|
|
107
|
-
};
|
|
108
|
-
},
|
|
109
|
-
newChange: async (cwd: string, changeName: string, description?: string) => {
|
|
110
|
-
const nextChangeDir = path.join(cwd, "openspec", "changes", changeName);
|
|
111
|
-
await mkdir(nextChangeDir, { recursive: true });
|
|
112
|
-
await writeUtf8(path.join(nextChangeDir, ".openspec.yaml"), "schema: spec-driven\n");
|
|
113
|
-
await writeUtf8(path.join(nextChangeDir, "proposal.md"), `# ${changeName}\n${description ?? ""}\n`);
|
|
114
|
-
return {
|
|
115
|
-
command: description
|
|
116
|
-
? `openspec new change ${changeName} --description "${description}"`
|
|
117
|
-
: `openspec new change ${changeName}`,
|
|
118
|
-
cwd,
|
|
119
|
-
stdout: "change created",
|
|
120
|
-
stderr: "",
|
|
121
|
-
durationMs: 1,
|
|
122
|
-
};
|
|
123
|
-
},
|
|
124
|
-
status: async (cwd: string, changeName: string) => ({
|
|
125
|
-
command: `openspec status --change ${changeName} --json`,
|
|
126
|
-
cwd,
|
|
127
|
-
stdout: "{}",
|
|
128
|
-
stderr: "",
|
|
129
|
-
durationMs: 1,
|
|
130
|
-
parsed: {
|
|
131
|
-
changeName,
|
|
132
|
-
schemaName: "spec-driven",
|
|
133
|
-
isComplete: false,
|
|
134
|
-
applyRequires: ["tasks"],
|
|
135
|
-
artifacts: [
|
|
136
|
-
{ id: "proposal", outputPath: path.join(changeDir, "proposal.md"), status: "done" },
|
|
137
|
-
{ id: "tasks", outputPath: path.join(changeDir, "tasks.md"), status: "ready" },
|
|
138
|
-
],
|
|
139
|
-
},
|
|
140
|
-
}),
|
|
141
|
-
instructionsArtifact: async (cwd: string, artifactId: string, changeName: string) => ({
|
|
142
|
-
command: `openspec instructions ${artifactId} --change ${changeName} --json`,
|
|
143
|
-
cwd,
|
|
144
|
-
stdout: "{}",
|
|
145
|
-
stderr: "",
|
|
146
|
-
durationMs: 1,
|
|
147
|
-
parsed: {
|
|
148
|
-
changeName,
|
|
149
|
-
artifactId,
|
|
150
|
-
schemaName: "spec-driven",
|
|
151
|
-
changeDir,
|
|
152
|
-
outputPath: artifactId === "specs"
|
|
153
|
-
? path.join(changeDir, "specs", "demo-spec", "spec.md")
|
|
154
|
-
: path.join(changeDir, `${artifactId}.md`),
|
|
155
|
-
description: `Refresh ${artifactId}`,
|
|
156
|
-
instruction: `Use ${artifactId} template`,
|
|
157
|
-
template: `# ${artifactId}`,
|
|
158
|
-
dependencies: [],
|
|
159
|
-
unlocks: [],
|
|
160
|
-
},
|
|
161
|
-
}),
|
|
162
|
-
instructionsApply: async (cwd: string, changeName: string) => ({
|
|
163
|
-
command: `openspec instructions apply --change ${changeName} --json`,
|
|
164
|
-
cwd,
|
|
165
|
-
stdout: "{}",
|
|
166
|
-
stderr: "",
|
|
167
|
-
durationMs: 1,
|
|
168
|
-
parsed: {
|
|
169
|
-
changeName,
|
|
170
|
-
changeDir,
|
|
171
|
-
schemaName: "spec-driven",
|
|
172
|
-
contextFiles: {},
|
|
173
|
-
progress: { total: 0, complete: 0, remaining: 0 },
|
|
174
|
-
tasks: [],
|
|
175
|
-
state: "ready",
|
|
176
|
-
instruction: "Implement the remaining tasks.",
|
|
177
|
-
},
|
|
178
|
-
}),
|
|
179
|
-
} as Record<string, any>;
|
|
180
|
-
|
|
181
|
-
const watcherManager = createFakeWatcherManager();
|
|
182
|
-
const sentMessages: Array<{ to: string; text: string }> = [];
|
|
183
|
-
const service = new ClawSpecService({
|
|
184
|
-
api: {
|
|
185
|
-
config: {
|
|
186
|
-
acp: {
|
|
187
|
-
backend: "acpx",
|
|
188
|
-
defaultAgent: "codex",
|
|
189
|
-
allowedAgents: ["codex", "piper"],
|
|
190
|
-
},
|
|
191
|
-
agents: {
|
|
192
|
-
list: [
|
|
193
|
-
{ id: "codex" },
|
|
194
|
-
{ id: "piper" },
|
|
195
|
-
],
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
runtime: {
|
|
199
|
-
channel: {
|
|
200
|
-
discord: {
|
|
201
|
-
sendMessageDiscord: async (to: string, text: string) => {
|
|
202
|
-
sentMessages.push({ to, text });
|
|
203
|
-
},
|
|
204
|
-
},
|
|
205
|
-
telegram: {},
|
|
206
|
-
slack: {},
|
|
207
|
-
signal: {},
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
logger: createLogger(),
|
|
211
|
-
} as any,
|
|
212
|
-
config: {
|
|
213
|
-
acp: {
|
|
214
|
-
backend: "acpx",
|
|
215
|
-
defaultAgent: "codex",
|
|
216
|
-
allowedAgents: ["codex", "piper"],
|
|
217
|
-
},
|
|
218
|
-
agents: {
|
|
219
|
-
list: [
|
|
220
|
-
{ id: "codex" },
|
|
221
|
-
{ id: "piper" },
|
|
222
|
-
],
|
|
223
|
-
},
|
|
224
|
-
} as any,
|
|
225
|
-
logger: createLogger(),
|
|
226
|
-
stateStore,
|
|
227
|
-
memoryStore,
|
|
228
|
-
openSpec: openSpec as any,
|
|
229
|
-
archiveDirName: "archives",
|
|
230
|
-
defaultWorkspace: workspacePath,
|
|
231
|
-
defaultWorkerAgentId: undefined,
|
|
232
|
-
workspaceStore,
|
|
233
|
-
watcherManager: watcherManager as any,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
return {
|
|
237
|
-
service,
|
|
238
|
-
stateStore,
|
|
239
|
-
memoryStore,
|
|
240
|
-
workspaceStore,
|
|
241
|
-
watcherManager,
|
|
242
|
-
sentMessages,
|
|
243
|
-
workspacePath,
|
|
244
|
-
repoPath,
|
|
245
|
-
changeDir,
|
|
246
|
-
openSpec,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export async function seedPlanningProject(
|
|
251
|
-
stateStore: ProjectStateStore,
|
|
252
|
-
channelKey: string,
|
|
253
|
-
params: {
|
|
254
|
-
workspacePath: string;
|
|
255
|
-
repoPath: string;
|
|
256
|
-
projectName: string;
|
|
257
|
-
changeName: string;
|
|
258
|
-
changeDir: string;
|
|
259
|
-
phase: "proposal" | "planning_sync" | "tasks" | "implementing";
|
|
260
|
-
status: "ready" | "paused" | "planning" | "running";
|
|
261
|
-
planningDirty: boolean;
|
|
262
|
-
execution?: {
|
|
263
|
-
action: "plan" | "work";
|
|
264
|
-
state: "armed" | "running";
|
|
265
|
-
mode: "apply" | "continue";
|
|
266
|
-
};
|
|
267
|
-
},
|
|
268
|
-
): Promise<void> {
|
|
269
|
-
await mkdir(params.changeDir, { recursive: true });
|
|
270
|
-
const repoStatePaths = getRepoStatePaths(params.repoPath, "archives");
|
|
271
|
-
const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
|
|
272
|
-
await stateStore.createProject(channelKey);
|
|
273
|
-
await stateStore.updateProject(channelKey, (current) => ({
|
|
274
|
-
...current,
|
|
275
|
-
workspacePath: params.workspacePath,
|
|
276
|
-
repoPath: params.repoPath,
|
|
277
|
-
projectName: params.projectName,
|
|
278
|
-
projectTitle: params.projectName,
|
|
279
|
-
changeName: params.changeName,
|
|
280
|
-
changeDir: params.changeDir,
|
|
281
|
-
status: params.status,
|
|
282
|
-
phase: params.phase,
|
|
283
|
-
planningJournal: {
|
|
284
|
-
dirty: params.planningDirty,
|
|
285
|
-
entryCount: params.planningDirty ? 1 : 0,
|
|
286
|
-
lastEntryAt: params.planningDirty ? new Date(Date.now() - 60_000).toISOString() : undefined,
|
|
287
|
-
},
|
|
288
|
-
execution: params.execution
|
|
289
|
-
? {
|
|
290
|
-
...params.execution,
|
|
291
|
-
workerSlot: "primary",
|
|
292
|
-
armedAt: new Date().toISOString(),
|
|
293
|
-
}
|
|
294
|
-
: current.execution,
|
|
295
|
-
}));
|
|
296
|
-
|
|
297
|
-
if (params.planningDirty) {
|
|
298
|
-
await journalStore.append({
|
|
299
|
-
timestamp: new Date(Date.now() - 60_000).toISOString(),
|
|
300
|
-
changeName: params.changeName,
|
|
301
|
-
role: "user",
|
|
302
|
-
text: "refresh planning for the active change",
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
package/test/helpers.test.ts
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import type { ProjectState } from "../src/types.ts";
|
|
4
|
-
import {
|
|
5
|
-
hasBlockingExecution,
|
|
6
|
-
isFinishedStatus,
|
|
7
|
-
isProjectContextAttached,
|
|
8
|
-
requiresPlanningSync,
|
|
9
|
-
sanitizePlanningMessageText,
|
|
10
|
-
shouldCapturePlanningMessage,
|
|
11
|
-
isMeaningfulExecutionSummary,
|
|
12
|
-
okReply,
|
|
13
|
-
errorReply,
|
|
14
|
-
samePath,
|
|
15
|
-
} from "../src/orchestrator/helpers.ts";
|
|
16
|
-
|
|
17
|
-
function makeProject(overrides: Partial<ProjectState> = {}): ProjectState {
|
|
18
|
-
return {
|
|
19
|
-
projectId: "test-id",
|
|
20
|
-
channelKey: "discord:test:default:main",
|
|
21
|
-
projectName: "test",
|
|
22
|
-
status: "ready",
|
|
23
|
-
phase: "tasks",
|
|
24
|
-
createdAt: new Date().toISOString(),
|
|
25
|
-
updatedAt: new Date().toISOString(),
|
|
26
|
-
...overrides,
|
|
27
|
-
} as ProjectState;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
test("hasBlockingExecution detects armed/running states", () => {
|
|
31
|
-
assert.equal(hasBlockingExecution(makeProject({ execution: { state: "armed" } as any })), true);
|
|
32
|
-
assert.equal(hasBlockingExecution(makeProject({ execution: { state: "running" } as any })), true);
|
|
33
|
-
assert.equal(hasBlockingExecution(makeProject({ status: "running" })), true);
|
|
34
|
-
assert.equal(hasBlockingExecution(makeProject({ status: "ready" })), false);
|
|
35
|
-
assert.equal(hasBlockingExecution(makeProject()), false);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("isFinishedStatus identifies terminal statuses", () => {
|
|
39
|
-
assert.equal(isFinishedStatus("done"), true);
|
|
40
|
-
assert.equal(isFinishedStatus("archived"), true);
|
|
41
|
-
assert.equal(isFinishedStatus("cancelled"), true);
|
|
42
|
-
assert.equal(isFinishedStatus("ready"), false);
|
|
43
|
-
assert.equal(isFinishedStatus("running"), false);
|
|
44
|
-
assert.equal(isFinishedStatus("armed"), false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("isProjectContextAttached returns false when detached", () => {
|
|
48
|
-
assert.equal(isProjectContextAttached(makeProject({ contextMode: "detached" })), false);
|
|
49
|
-
assert.equal(isProjectContextAttached(makeProject({ contextMode: undefined })), true);
|
|
50
|
-
assert.equal(isProjectContextAttached(makeProject()), true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("requiresPlanningSync detects dirty journal", () => {
|
|
54
|
-
assert.equal(requiresPlanningSync(makeProject({ changeName: "x", planningJournal: { dirty: true, entryCount: 1 } })), true);
|
|
55
|
-
assert.equal(requiresPlanningSync(makeProject({ changeName: "x", phase: "proposal" })), true);
|
|
56
|
-
assert.equal(requiresPlanningSync(makeProject({ changeName: "x", planningJournal: { dirty: false, entryCount: 0 } })), false);
|
|
57
|
-
assert.equal(requiresPlanningSync(makeProject({ changeName: undefined as any })), false);
|
|
58
|
-
assert.equal(requiresPlanningSync(makeProject({ changeName: "x", status: "done" })), false);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("shouldCapturePlanningMessage excludes running/archived/cancelled", () => {
|
|
62
|
-
assert.equal(shouldCapturePlanningMessage(makeProject({ changeName: "x", status: "ready" })), true);
|
|
63
|
-
assert.equal(shouldCapturePlanningMessage(makeProject({ changeName: "x", status: "running" })), false);
|
|
64
|
-
assert.equal(shouldCapturePlanningMessage(makeProject({ changeName: "x", status: "archived" })), false);
|
|
65
|
-
assert.equal(shouldCapturePlanningMessage(makeProject({ changeName: "x", status: "cancelled" })), false);
|
|
66
|
-
assert.equal(shouldCapturePlanningMessage(makeProject({ contextMode: "detached", changeName: "x" })), false);
|
|
67
|
-
assert.equal(shouldCapturePlanningMessage(makeProject({ changeName: undefined as any })), false);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("sanitizePlanningMessageText strips metadata blocks", () => {
|
|
71
|
-
const input = `Hello\nConversation info (untrusted metadata):\n\`\`\`json\n{"key":"value"}\n\`\`\`\nWorld`;
|
|
72
|
-
const result = sanitizePlanningMessageText(input);
|
|
73
|
-
assert.equal(result, "Hello\nWorld");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("sanitizePlanningMessageText preserves clean text", () => {
|
|
77
|
-
assert.equal(sanitizePlanningMessageText(" clean text "), "clean text");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("isMeaningfulExecutionSummary filters boilerplate", () => {
|
|
81
|
-
assert.equal(isMeaningfulExecutionSummary(undefined), false);
|
|
82
|
-
assert.equal(isMeaningfulExecutionSummary(""), false);
|
|
83
|
-
assert.equal(isMeaningfulExecutionSummary("No summary yet."), false);
|
|
84
|
-
assert.equal(isMeaningfulExecutionSummary("Visible execution ended without a structured result."), false);
|
|
85
|
-
assert.equal(isMeaningfulExecutionSummary("Visible execution started for something"), false);
|
|
86
|
-
assert.equal(isMeaningfulExecutionSummary("Completed task 1.1"), true);
|
|
87
|
-
assert.equal(isMeaningfulExecutionSummary("All tasks done."), true);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("okReply and errorReply produce correct shapes", () => {
|
|
91
|
-
const ok = okReply("success");
|
|
92
|
-
assert.equal(ok.text, "success");
|
|
93
|
-
assert.equal(ok.isError, undefined);
|
|
94
|
-
|
|
95
|
-
const err = errorReply("failure");
|
|
96
|
-
assert.equal(err.text, "failure");
|
|
97
|
-
assert.equal(err.isError, true);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("samePath respects platform case sensitivity rules", () => {
|
|
101
|
-
if (process.platform === "win32") {
|
|
102
|
-
assert.equal(samePath("C:\\Repo\\Demo", "c:\\repo\\demo"), true);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
assert.equal(samePath("/Repo/Demo", "/repo/demo"), false);
|
|
107
|
-
assert.equal(samePath("/repo/demo", "/repo/demo"), true);
|
|
108
|
-
});
|
package/test/keywords.test.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
4
|
-
parseClawSpecKeyword,
|
|
5
|
-
extractEmbeddedClawSpecKeyword,
|
|
6
|
-
isClawSpecKeywordText,
|
|
7
|
-
} from "../src/control/keywords.ts";
|
|
8
|
-
|
|
9
|
-
test("parseClawSpecKeyword recognizes all primary commands", () => {
|
|
10
|
-
const commands = [
|
|
11
|
-
["cs-plan", "plan"],
|
|
12
|
-
["cs-work", "work"],
|
|
13
|
-
["cs-attach", "attach"],
|
|
14
|
-
["cs-detach", "detach"],
|
|
15
|
-
["cs-deattach", "detach"],
|
|
16
|
-
["cs-pause", "pause"],
|
|
17
|
-
["cs-continue", "continue"],
|
|
18
|
-
["cs-status", "status"],
|
|
19
|
-
["cs-cancel", "cancel"],
|
|
20
|
-
] as const;
|
|
21
|
-
|
|
22
|
-
for (const [input, expectedKind] of commands) {
|
|
23
|
-
const result = parseClawSpecKeyword(input);
|
|
24
|
-
assert.equal(result?.kind, expectedKind, `${input} should be kind "${expectedKind}"`);
|
|
25
|
-
assert.equal(result?.command, input.toLowerCase());
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("parseClawSpecKeyword handles args", () => {
|
|
30
|
-
const result = parseClawSpecKeyword("cs-continue now please");
|
|
31
|
-
assert.equal(result?.kind, "continue");
|
|
32
|
-
assert.equal(result?.args, "now please");
|
|
33
|
-
assert.equal(result?.raw, "cs-continue now please");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("parseClawSpecKeyword returns null for non-cs- prefixed text", () => {
|
|
37
|
-
assert.equal(parseClawSpecKeyword("hello world"), null);
|
|
38
|
-
assert.equal(parseClawSpecKeyword("/clawspec proposal"), null);
|
|
39
|
-
assert.equal(parseClawSpecKeyword(""), null);
|
|
40
|
-
assert.equal(parseClawSpecKeyword("cs-nonexistent"), null);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("parseClawSpecKeyword is case-insensitive for command", () => {
|
|
44
|
-
const result = parseClawSpecKeyword("CS-PLAN");
|
|
45
|
-
assert.equal(result?.kind, "plan");
|
|
46
|
-
assert.equal(result?.command, "cs-plan");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("parseClawSpecKeyword trims input", () => {
|
|
50
|
-
const result = parseClawSpecKeyword(" cs-work ");
|
|
51
|
-
assert.equal(result?.kind, "work");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("parseClawSpecKeyword accepts slash-prefixed and punctuated control words", () => {
|
|
55
|
-
assert.equal(parseClawSpecKeyword("/cs-work")?.kind, "work");
|
|
56
|
-
assert.equal(parseClawSpecKeyword("`cs-plan`")?.kind, "plan");
|
|
57
|
-
assert.equal(parseClawSpecKeyword("cs-work。")?.kind, "work");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("isClawSpecKeywordText returns boolean correctly", () => {
|
|
61
|
-
assert.equal(isClawSpecKeywordText("cs-plan"), true);
|
|
62
|
-
assert.equal(isClawSpecKeywordText("hello"), false);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("extractEmbeddedClawSpecKeyword finds keyword in multiline text", () => {
|
|
66
|
-
const text = "Here is some context\n\ncs-work\n\nMore text after";
|
|
67
|
-
const result = extractEmbeddedClawSpecKeyword(text);
|
|
68
|
-
assert.equal(result?.kind, "work");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("extractEmbeddedClawSpecKeyword finds keyword with args in multiline text", () => {
|
|
72
|
-
const text = "Please do this:\ncs-continue hello world\nThanks!";
|
|
73
|
-
const result = extractEmbeddedClawSpecKeyword(text);
|
|
74
|
-
assert.equal(result?.kind, "continue");
|
|
75
|
-
assert.equal(result?.args, "hello world");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("extractEmbeddedClawSpecKeyword finds slash-prefixed keyword in multiline text", () => {
|
|
79
|
-
const text = "Summary:\n/cs-work\nProceed please.";
|
|
80
|
-
const result = extractEmbeddedClawSpecKeyword(text);
|
|
81
|
-
assert.equal(result?.kind, "work");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("extractEmbeddedClawSpecKeyword returns direct match for single-line input", () => {
|
|
85
|
-
const result = extractEmbeddedClawSpecKeyword("cs-status");
|
|
86
|
-
assert.equal(result?.kind, "status");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("extractEmbeddedClawSpecKeyword returns null when no keyword present", () => {
|
|
90
|
-
assert.equal(extractEmbeddedClawSpecKeyword("no keywords here"), null);
|
|
91
|
-
assert.equal(extractEmbeddedClawSpecKeyword("just a normal message\nwith multiple lines"), null);
|
|
92
|
-
});
|
package/test/notifier.test.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { ClawSpecNotifier } from "../src/watchers/notifier.ts";
|
|
4
|
-
import { createLogger } from "./helpers/harness.ts";
|
|
5
|
-
|
|
6
|
-
test("notifier formats Discord channel target correctly", async () => {
|
|
7
|
-
const sent: Array<{ to: string; text: string }> = [];
|
|
8
|
-
const notifier = new ClawSpecNotifier({
|
|
9
|
-
api: {
|
|
10
|
-
config: {},
|
|
11
|
-
runtime: {
|
|
12
|
-
channel: {
|
|
13
|
-
discord: {
|
|
14
|
-
sendMessageDiscord: async (to: string, text: string) => {
|
|
15
|
-
sent.push({ to, text });
|
|
16
|
-
},
|
|
17
|
-
},
|
|
18
|
-
telegram: {},
|
|
19
|
-
slack: {},
|
|
20
|
-
signal: {},
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
} as any,
|
|
24
|
-
logger: createLogger() as any,
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
await notifier.send("discord:1474686041939251210:default:main", "hello");
|
|
28
|
-
assert.deepEqual(sent, [{ to: "channel:1474686041939251210", text: "hello" }]);
|
|
29
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { buildOpenSpecInstallMessage, ensureOpenSpecCli } from "../src/dependencies/openspec.ts";
|
|
5
|
-
|
|
6
|
-
const ROOT_PREFIX = process.platform === "win32" ? "C:\\clawspec-test" : "/tmp/clawspec-test";
|
|
7
|
-
|
|
8
|
-
const LOCAL_COMMAND = path.join(
|
|
9
|
-
ROOT_PREFIX,
|
|
10
|
-
"node_modules",
|
|
11
|
-
".bin",
|
|
12
|
-
process.platform === "win32" ? "openspec.cmd" : "openspec",
|
|
13
|
-
);
|
|
14
|
-
|
|
15
|
-
test("ensureOpenSpecCli uses the global openspec command when available", async () => {
|
|
16
|
-
const calls: Array<{ command: string; args: string[] }> = [];
|
|
17
|
-
const result = await ensureOpenSpecCli({
|
|
18
|
-
pluginRoot: ROOT_PREFIX,
|
|
19
|
-
runner: async ({ command, args }) => {
|
|
20
|
-
calls.push({ command, args });
|
|
21
|
-
if (command === LOCAL_COMMAND) {
|
|
22
|
-
return { code: 1, stdout: "", stderr: "not found" };
|
|
23
|
-
}
|
|
24
|
-
if (command === "openspec") {
|
|
25
|
-
return { code: 0, stdout: "1.2.0\n", stderr: "" };
|
|
26
|
-
}
|
|
27
|
-
return { code: 1, stdout: "", stderr: "unexpected command" };
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
assert.equal(result.source, "global");
|
|
32
|
-
assert.equal(result.version, "1.2.0");
|
|
33
|
-
assert.equal(calls.some((call) => call.command === "npm"), false);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("ensureOpenSpecCli installs plugin-local openspec when local and global are unavailable", async () => {
|
|
37
|
-
const calls: Array<{ command: string; args: string[] }> = [];
|
|
38
|
-
const result = await ensureOpenSpecCli({
|
|
39
|
-
pluginRoot: ROOT_PREFIX,
|
|
40
|
-
runner: async ({ command, args }) => {
|
|
41
|
-
calls.push({ command, args });
|
|
42
|
-
if (command === LOCAL_COMMAND) {
|
|
43
|
-
const versionChecks = calls.filter((call) => call.command === LOCAL_COMMAND && call.args[0] === "--version").length;
|
|
44
|
-
if (versionChecks >= 2) {
|
|
45
|
-
return { code: 0, stdout: "1.2.3\n", stderr: "" };
|
|
46
|
-
}
|
|
47
|
-
return { code: 1, stdout: "", stderr: "not found" };
|
|
48
|
-
}
|
|
49
|
-
if (command === "openspec") {
|
|
50
|
-
return { code: 1, stdout: "", stderr: "not found" };
|
|
51
|
-
}
|
|
52
|
-
if (command === "npm") {
|
|
53
|
-
return { code: 0, stdout: "installed", stderr: "" };
|
|
54
|
-
}
|
|
55
|
-
return { code: 1, stdout: "", stderr: "unexpected command" };
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
assert.equal(result.source, "local");
|
|
60
|
-
assert.equal(result.version, "1.2.3");
|
|
61
|
-
assert.equal(calls.some((call) => call.command === "npm"), true);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("buildOpenSpecInstallMessage includes install command", () => {
|
|
65
|
-
const message = buildOpenSpecInstallMessage("not found");
|
|
66
|
-
assert.match(message, /npm install -g @fission-ai\/openspec/);
|
|
67
|
-
assert.match(message, /not found/);
|
|
68
|
-
});
|
package/test/paths-utils.test.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { resolveUserPath } from "../src/utils/paths.ts";
|
|
6
|
-
|
|
7
|
-
test("resolveUserPath expands home directory shorthand", () => {
|
|
8
|
-
const actual = resolveUserPath("~/Desktop/workspace/ai_workspace", "/tmp/base");
|
|
9
|
-
const expected = path.join(os.homedir(), "Desktop", "workspace", "ai_workspace");
|
|
10
|
-
assert.equal(actual, expected);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("resolveUserPath resolves relative path against base directory", () => {
|
|
14
|
-
const base = path.join(os.homedir(), "clawspec", "workspace");
|
|
15
|
-
const actual = resolveUserPath("demo-app", base);
|
|
16
|
-
assert.equal(actual, path.resolve(base, "demo-app"));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("resolveUserPath keeps POSIX absolute path absolute", () => {
|
|
20
|
-
const absolute = "/tmp/clawspec-posix-absolute";
|
|
21
|
-
const actual = resolveUserPath(absolute, "/tmp/base");
|
|
22
|
-
assert.equal(actual, path.normalize(absolute));
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("resolveUserPath keeps Windows drive absolute path absolute on all platforms", () => {
|
|
26
|
-
const absolute = "C:\\Users\\dev\\workspace\\demo";
|
|
27
|
-
const actual = resolveUserPath(absolute, "/tmp/base");
|
|
28
|
-
assert.equal(actual, path.normalize(absolute));
|
|
29
|
-
});
|
|
30
|
-
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { createServiceHarness, seedPlanningProject } from "./helpers/harness.ts";
|
|
4
|
-
|
|
5
|
-
test("pause requests watcher interrupt", async () => {
|
|
6
|
-
const harness = await createServiceHarness("clawspec-pause-");
|
|
7
|
-
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
8
|
-
const channelKey = "discord:pause:default:main";
|
|
9
|
-
|
|
10
|
-
await seedPlanningProject(stateStore, channelKey, {
|
|
11
|
-
workspacePath,
|
|
12
|
-
repoPath,
|
|
13
|
-
projectName: "demo-app",
|
|
14
|
-
changeName: "pause-change",
|
|
15
|
-
changeDir,
|
|
16
|
-
phase: "implementing",
|
|
17
|
-
status: "running",
|
|
18
|
-
planningDirty: false,
|
|
19
|
-
execution: { action: "work", state: "running", mode: "apply" },
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
const result = await service.pauseProject(channelKey);
|
|
23
|
-
const project = await stateStore.getActiveProject(channelKey);
|
|
24
|
-
|
|
25
|
-
assert.match(result.text ?? "", /Pause Requested/);
|
|
26
|
-
assert.equal(project?.pauseRequested, true);
|
|
27
|
-
assert.deepEqual(watcherManager.interruptCalls, [{ channelKey, reason: "paused by user" }]);
|
|
28
|
-
assert.deepEqual(watcherManager.wakeCalls, [channelKey]);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("cancel requests watcher interrupt", async () => {
|
|
32
|
-
const harness = await createServiceHarness("clawspec-cancel-");
|
|
33
|
-
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
34
|
-
const channelKey = "discord:cancel:default:main";
|
|
35
|
-
|
|
36
|
-
await seedPlanningProject(stateStore, channelKey, {
|
|
37
|
-
workspacePath,
|
|
38
|
-
repoPath,
|
|
39
|
-
projectName: "demo-app",
|
|
40
|
-
changeName: "cancel-change",
|
|
41
|
-
changeDir,
|
|
42
|
-
phase: "implementing",
|
|
43
|
-
status: "running",
|
|
44
|
-
planningDirty: false,
|
|
45
|
-
execution: { action: "work", state: "running", mode: "apply" },
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const result = await service.cancelProject(channelKey);
|
|
49
|
-
const project = await stateStore.getActiveProject(channelKey);
|
|
50
|
-
|
|
51
|
-
assert.match(result.text ?? "", /Cancellation Requested/);
|
|
52
|
-
assert.equal(project?.cancelRequested, true);
|
|
53
|
-
assert.deepEqual(watcherManager.interruptCalls, [{ channelKey, reason: "cancelled by user" }]);
|
|
54
|
-
assert.deepEqual(watcherManager.wakeCalls, [channelKey]);
|
|
55
|
-
});
|