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.
- package/README.md +908 -0
- package/README.zh-CN.md +914 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +129 -0
- package/package.json +52 -0
- package/skills/openspec-apply-change.md +146 -0
- package/skills/openspec-explore.md +75 -0
- package/skills/openspec-propose.md +102 -0
- package/src/acp/client.ts +693 -0
- package/src/config.ts +220 -0
- package/src/control/keywords.ts +72 -0
- package/src/dependencies/acpx.ts +221 -0
- package/src/dependencies/openspec.ts +148 -0
- package/src/execution/session.ts +56 -0
- package/src/execution/state.ts +125 -0
- package/src/index.ts +179 -0
- package/src/memory/store.ts +118 -0
- package/src/openspec/cli.ts +279 -0
- package/src/openspec/tasks.ts +40 -0
- package/src/orchestrator/helpers.ts +312 -0
- package/src/orchestrator/service.ts +2971 -0
- package/src/planning/journal.ts +118 -0
- package/src/rollback/store.ts +173 -0
- package/src/state/locks.ts +133 -0
- package/src/state/store.ts +527 -0
- package/src/types.ts +301 -0
- package/src/utils/args.ts +88 -0
- package/src/utils/channel-key.ts +66 -0
- package/src/utils/env-path.ts +31 -0
- package/src/utils/fs.ts +218 -0
- package/src/utils/markdown.ts +136 -0
- package/src/utils/messages.ts +5 -0
- package/src/utils/paths.ts +127 -0
- package/src/utils/shell-command.ts +227 -0
- package/src/utils/slug.ts +50 -0
- package/src/watchers/manager.ts +3042 -0
- package/src/watchers/notifier.ts +69 -0
- package/src/worker/prompts.ts +484 -0
- package/src/worker/skills.ts +52 -0
- package/src/workspace/store.ts +140 -0
- package/test/acp-client.test.ts +234 -0
- package/test/acpx-dependency.test.ts +112 -0
- package/test/assistant-journal.test.ts +136 -0
- package/test/command-surface.test.ts +23 -0
- package/test/config.test.ts +77 -0
- package/test/detach-attach.test.ts +98 -0
- package/test/file-lock.test.ts +78 -0
- package/test/fs-utils.test.ts +22 -0
- package/test/helpers/harness.ts +241 -0
- package/test/helpers.test.ts +108 -0
- package/test/keywords.test.ts +80 -0
- package/test/notifier.test.ts +29 -0
- package/test/openspec-dependency.test.ts +67 -0
- package/test/pause-cancel.test.ts +55 -0
- package/test/planning-journal.test.ts +69 -0
- package/test/plugin-registration.test.ts +35 -0
- package/test/project-memory.test.ts +42 -0
- package/test/proposal.test.ts +24 -0
- package/test/queue-planning.test.ts +247 -0
- package/test/queue-work.test.ts +110 -0
- package/test/recovery.test.ts +576 -0
- package/test/service-archive.test.ts +82 -0
- package/test/shell-command.test.ts +48 -0
- package/test/state-store.test.ts +74 -0
- package/test/tasks-and-checkpoint.test.ts +60 -0
- package/test/use-project.test.ts +19 -0
- package/test/watcher-planning.test.ts +504 -0
- package/test/watcher-work.test.ts +1741 -0
- package/test/worker-command.test.ts +66 -0
- package/test/worker-skills.test.ts +12 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,80 @@
|
|
|
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("isClawSpecKeywordText returns boolean correctly", () => {
|
|
55
|
+
assert.equal(isClawSpecKeywordText("cs-plan"), true);
|
|
56
|
+
assert.equal(isClawSpecKeywordText("hello"), false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("extractEmbeddedClawSpecKeyword finds keyword in multiline text", () => {
|
|
60
|
+
const text = "Here is some context\n\ncs-work\n\nMore text after";
|
|
61
|
+
const result = extractEmbeddedClawSpecKeyword(text);
|
|
62
|
+
assert.equal(result?.kind, "work");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("extractEmbeddedClawSpecKeyword finds keyword with args in multiline text", () => {
|
|
66
|
+
const text = "Please do this:\ncs-continue hello world\nThanks!";
|
|
67
|
+
const result = extractEmbeddedClawSpecKeyword(text);
|
|
68
|
+
assert.equal(result?.kind, "continue");
|
|
69
|
+
assert.equal(result?.args, "hello world");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("extractEmbeddedClawSpecKeyword returns direct match for single-line input", () => {
|
|
73
|
+
const result = extractEmbeddedClawSpecKeyword("cs-status");
|
|
74
|
+
assert.equal(result?.kind, "status");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("extractEmbeddedClawSpecKeyword returns null when no keyword present", () => {
|
|
78
|
+
assert.equal(extractEmbeddedClawSpecKeyword("no keywords here"), null);
|
|
79
|
+
assert.equal(extractEmbeddedClawSpecKeyword("just a normal message\nwith multiple lines"), null);
|
|
80
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ensureOpenSpecCli, OPENSPEC_PACKAGE_NAME } 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 a plugin-local openspec when none is available", async () => {
|
|
37
|
+
const calls: Array<{ command: string; args: string[] }> = [];
|
|
38
|
+
let localCheckCount = 0;
|
|
39
|
+
|
|
40
|
+
const result = await ensureOpenSpecCli({
|
|
41
|
+
pluginRoot: ROOT_PREFIX,
|
|
42
|
+
runner: async ({ command, args }) => {
|
|
43
|
+
calls.push({ command, args });
|
|
44
|
+
if (command === LOCAL_COMMAND) {
|
|
45
|
+
localCheckCount += 1;
|
|
46
|
+
if (localCheckCount === 1) {
|
|
47
|
+
return { code: 1, stdout: "", stderr: "not found" };
|
|
48
|
+
}
|
|
49
|
+
return { code: 0, stdout: "1.2.0\n", stderr: "" };
|
|
50
|
+
}
|
|
51
|
+
if (command === "openspec") {
|
|
52
|
+
return { code: 1, stdout: "", stderr: "not found" };
|
|
53
|
+
}
|
|
54
|
+
if (command === "npm") {
|
|
55
|
+
return { code: 0, stdout: "installed\n", stderr: "" };
|
|
56
|
+
}
|
|
57
|
+
return { code: 1, stdout: "", stderr: "unexpected command" };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.equal(result.source, "local");
|
|
62
|
+
assert.equal(result.version, "1.2.0");
|
|
63
|
+
assert.equal(
|
|
64
|
+
calls.some((call) => call.command === "npm" && call.args.includes(OPENSPEC_PACKAGE_NAME)),
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { mkdtemp } from "node:fs/promises";
|
|
6
|
+
import { PlanningJournalStore } from "../src/planning/journal.ts";
|
|
7
|
+
|
|
8
|
+
test("planning journal reports clean when snapshot matches current content", async () => {
|
|
9
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-journal-clean-"));
|
|
10
|
+
const journalPath = path.join(tempRoot, "planning-journal.jsonl");
|
|
11
|
+
const snapshotPath = path.join(tempRoot, "planning-journal.snapshot.json");
|
|
12
|
+
const store = new PlanningJournalStore(journalPath);
|
|
13
|
+
|
|
14
|
+
await store.append({
|
|
15
|
+
timestamp: "2026-03-23T07:00:00.000Z",
|
|
16
|
+
changeName: "hello",
|
|
17
|
+
role: "user",
|
|
18
|
+
text: "keep the two existing endpoints",
|
|
19
|
+
});
|
|
20
|
+
await store.writeSnapshot(snapshotPath, "hello", "2026-03-23T07:05:00.000Z");
|
|
21
|
+
|
|
22
|
+
assert.equal(await store.hasUnsyncedChanges("hello", snapshotPath), false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("planning journal reports dirty when a new entry is appended after the snapshot", async () => {
|
|
26
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-journal-dirty-"));
|
|
27
|
+
const journalPath = path.join(tempRoot, "planning-journal.jsonl");
|
|
28
|
+
const snapshotPath = path.join(tempRoot, "planning-journal.snapshot.json");
|
|
29
|
+
const store = new PlanningJournalStore(journalPath);
|
|
30
|
+
|
|
31
|
+
await store.append({
|
|
32
|
+
timestamp: "2026-03-23T07:00:00.000Z",
|
|
33
|
+
changeName: "hello",
|
|
34
|
+
role: "user",
|
|
35
|
+
text: "keep the two existing endpoints",
|
|
36
|
+
});
|
|
37
|
+
await store.writeSnapshot(snapshotPath, "hello", "2026-03-23T07:05:00.000Z");
|
|
38
|
+
await store.append({
|
|
39
|
+
timestamp: "2026-03-23T07:10:00.000Z",
|
|
40
|
+
changeName: "hello",
|
|
41
|
+
role: "user",
|
|
42
|
+
text: "add two new interfaces to the same change",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.equal(await store.hasUnsyncedChanges("hello", snapshotPath), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("planning journal falls back to lastSyncedAt when no snapshot exists yet", async () => {
|
|
49
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-journal-fallback-"));
|
|
50
|
+
const journalPath = path.join(tempRoot, "planning-journal.jsonl");
|
|
51
|
+
const snapshotPath = path.join(tempRoot, "planning-journal.snapshot.json");
|
|
52
|
+
const store = new PlanningJournalStore(journalPath);
|
|
53
|
+
|
|
54
|
+
await store.append({
|
|
55
|
+
timestamp: "2026-03-23T07:00:00.000Z",
|
|
56
|
+
changeName: "hello",
|
|
57
|
+
role: "user",
|
|
58
|
+
text: "keep the two existing endpoints",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.equal(
|
|
62
|
+
await store.hasUnsyncedChanges("hello", snapshotPath, "2026-03-23T07:05:00.000Z"),
|
|
63
|
+
false,
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
await store.hasUnsyncedChanges("hello", snapshotPath, "2026-03-23T06:55:00.000Z"),
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { mkdtemp } from "node:fs/promises";
|
|
6
|
+
import plugin from "../src/index.ts";
|
|
7
|
+
|
|
8
|
+
test("plugin registers the clawspec command", async () => {
|
|
9
|
+
const stateDir = await mkdtemp(path.join(os.tmpdir(), "clawspec-plugin-register-"));
|
|
10
|
+
const commandNames: string[] = [];
|
|
11
|
+
|
|
12
|
+
plugin.register({
|
|
13
|
+
pluginConfig: {},
|
|
14
|
+
config: {},
|
|
15
|
+
logger: {
|
|
16
|
+
info() {},
|
|
17
|
+
warn() {},
|
|
18
|
+
error() {},
|
|
19
|
+
debug() {},
|
|
20
|
+
},
|
|
21
|
+
runtime: {
|
|
22
|
+
state: {
|
|
23
|
+
resolveStateDir: () => stateDir,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
registerService() {},
|
|
27
|
+
registerCli() {},
|
|
28
|
+
registerCommand(command: { name: string }) {
|
|
29
|
+
commandNames.push(command.name);
|
|
30
|
+
},
|
|
31
|
+
on() {},
|
|
32
|
+
} as any);
|
|
33
|
+
|
|
34
|
+
assert.deepEqual(commandNames, ["clawspec"]);
|
|
35
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { mkdtemp, mkdir } from "node:fs/promises";
|
|
6
|
+
import {
|
|
7
|
+
DuplicateRememberedProjectError,
|
|
8
|
+
ProjectMemoryStore,
|
|
9
|
+
RememberedProjectPathInvalidError,
|
|
10
|
+
} from "../src/memory/store.ts";
|
|
11
|
+
|
|
12
|
+
test("project memory store remembers, lists, overwrites, and validates paths", async () => {
|
|
13
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-memory-"));
|
|
14
|
+
const memoryFile = path.join(tempRoot, "project-memory.json");
|
|
15
|
+
const repoA = path.join(tempRoot, "repo-a");
|
|
16
|
+
const repoB = path.join(tempRoot, "repo-b");
|
|
17
|
+
await mkdir(repoA, { recursive: true });
|
|
18
|
+
await mkdir(repoB, { recursive: true });
|
|
19
|
+
|
|
20
|
+
const store = new ProjectMemoryStore(memoryFile);
|
|
21
|
+
await store.initialize();
|
|
22
|
+
|
|
23
|
+
const created = await store.remember("demo", repoA);
|
|
24
|
+
assert.equal(created.created, true);
|
|
25
|
+
assert.equal(created.overwritten, false);
|
|
26
|
+
|
|
27
|
+
const entries = await store.list();
|
|
28
|
+
assert.equal(entries.length, 1);
|
|
29
|
+
assert.equal(entries[0].repoPath, repoA);
|
|
30
|
+
|
|
31
|
+
await assert.rejects(() => store.remember("demo", repoB), DuplicateRememberedProjectError);
|
|
32
|
+
|
|
33
|
+
const overwritten = await store.remember("demo", repoB, { overwrite: true });
|
|
34
|
+
assert.equal(overwritten.overwritten, true);
|
|
35
|
+
assert.equal((await store.resolveForUse("demo")).repoPath, repoB);
|
|
36
|
+
|
|
37
|
+
const staleFile = path.join(tempRoot, "stale-memory.json");
|
|
38
|
+
const staleStore = new ProjectMemoryStore(staleFile);
|
|
39
|
+
await staleStore.initialize();
|
|
40
|
+
await staleStore.remember("stale", path.join(tempRoot, "missing"));
|
|
41
|
+
await assert.rejects(() => staleStore.resolveForUse("stale"), RememberedProjectPathInvalidError);
|
|
42
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathExists } from "../src/utils/fs.ts";
|
|
5
|
+
import { createServiceHarness } from "./helpers/harness.ts";
|
|
6
|
+
|
|
7
|
+
test("proposal creates scaffold and rollback baseline", async () => {
|
|
8
|
+
const harness = await createServiceHarness("clawspec-proposal-");
|
|
9
|
+
const { service, stateStore, workspacePath } = harness;
|
|
10
|
+
const channelKey = "discord:proposal:default:main";
|
|
11
|
+
|
|
12
|
+
await service.startProject(channelKey);
|
|
13
|
+
await service.useProject(channelKey, "demo-app");
|
|
14
|
+
const result = await service.proposalProject(channelKey, "support-weather Add weather endpoints");
|
|
15
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
16
|
+
const changeDir = path.join(workspacePath, "demo-app", "openspec", "changes", "support-weather");
|
|
17
|
+
|
|
18
|
+
assert.match(result.text ?? "", /Proposal Ready/);
|
|
19
|
+
assert.equal(project?.changeName, "support-weather");
|
|
20
|
+
assert.equal(project?.status, "ready");
|
|
21
|
+
assert.equal(project?.phase, "proposal");
|
|
22
|
+
assert.equal(await pathExists(path.join(changeDir, ".openspec.yaml")), true);
|
|
23
|
+
assert.equal(await pathExists(project?.rollback?.manifestPath ?? ""), true);
|
|
24
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { pathExists, readJsonFile, writeUtf8 } from "../src/utils/fs.ts";
|
|
4
|
+
import { PlanningJournalStore } from "../src/planning/journal.ts";
|
|
5
|
+
import { getRepoStatePaths } from "../src/utils/paths.ts";
|
|
6
|
+
import { createServiceHarness, seedPlanningProject } from "./helpers/harness.ts";
|
|
7
|
+
|
|
8
|
+
test("apply prepares visible planning", async () => {
|
|
9
|
+
const harness = await createServiceHarness("clawspec-apply-queue-");
|
|
10
|
+
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
11
|
+
const channelKey = "discord:apply-queue:default:main";
|
|
12
|
+
|
|
13
|
+
await seedPlanningProject(stateStore, channelKey, {
|
|
14
|
+
workspacePath,
|
|
15
|
+
repoPath,
|
|
16
|
+
projectName: "demo-app",
|
|
17
|
+
changeName: "weather-plan",
|
|
18
|
+
changeDir,
|
|
19
|
+
phase: "proposal",
|
|
20
|
+
status: "ready",
|
|
21
|
+
planningDirty: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
25
|
+
await writeUtf8(repoStatePaths.executionControlFile, "{\"state\":\"running\"}\n");
|
|
26
|
+
await writeUtf8(repoStatePaths.executionResultFile, "{\"status\":\"running\"}\n");
|
|
27
|
+
await writeUtf8(repoStatePaths.workerProgressFile, "{\"kind\":\"task_start\"}\n");
|
|
28
|
+
|
|
29
|
+
const result = await service.queuePlanningProject(channelKey, "apply");
|
|
30
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
31
|
+
|
|
32
|
+
assert.match(result.text ?? "", /Planning Ready/);
|
|
33
|
+
assert.equal(project?.status, "ready");
|
|
34
|
+
assert.equal(project?.execution, undefined);
|
|
35
|
+
assert.deepEqual(watcherManager.wakeCalls, []);
|
|
36
|
+
assert.equal(await pathExists(repoStatePaths.executionControlFile), false);
|
|
37
|
+
assert.equal(await pathExists(repoStatePaths.executionResultFile), false);
|
|
38
|
+
assert.equal(await pathExists(repoStatePaths.workerProgressFile), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("continue routes back to planning when dirty", async () => {
|
|
42
|
+
const harness = await createServiceHarness("clawspec-continue-planning-");
|
|
43
|
+
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
44
|
+
const channelKey = "discord:continue-planning:default:main";
|
|
45
|
+
|
|
46
|
+
await seedPlanningProject(stateStore, channelKey, {
|
|
47
|
+
workspacePath,
|
|
48
|
+
repoPath,
|
|
49
|
+
projectName: "demo-app",
|
|
50
|
+
changeName: "dirty-plan",
|
|
51
|
+
changeDir,
|
|
52
|
+
phase: "tasks",
|
|
53
|
+
status: "paused",
|
|
54
|
+
planningDirty: true,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const result = await service.continueProject(channelKey);
|
|
58
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
59
|
+
|
|
60
|
+
assert.match(result.text ?? "", /Planning Ready/);
|
|
61
|
+
assert.equal(project?.status, "ready");
|
|
62
|
+
assert.equal(project?.execution, undefined);
|
|
63
|
+
assert.deepEqual(watcherManager.wakeCalls, []);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("apply still prepares visible planning review when attached and journal matches the last snapshot", async () => {
|
|
67
|
+
const harness = await createServiceHarness("clawspec-apply-no-new-plan-");
|
|
68
|
+
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
69
|
+
const channelKey = "discord:apply-no-new-plan:default:main";
|
|
70
|
+
|
|
71
|
+
await seedPlanningProject(stateStore, channelKey, {
|
|
72
|
+
workspacePath,
|
|
73
|
+
repoPath,
|
|
74
|
+
projectName: "demo-app",
|
|
75
|
+
changeName: "weather-plan",
|
|
76
|
+
changeDir,
|
|
77
|
+
phase: "tasks",
|
|
78
|
+
status: "ready",
|
|
79
|
+
planningDirty: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
83
|
+
const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
|
|
84
|
+
await journalStore.append({
|
|
85
|
+
timestamp: new Date(Date.now() - 60_000).toISOString(),
|
|
86
|
+
changeName: "weather-plan",
|
|
87
|
+
role: "user",
|
|
88
|
+
text: "keep the same two API endpoints",
|
|
89
|
+
});
|
|
90
|
+
await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, "weather-plan");
|
|
91
|
+
|
|
92
|
+
const result = await service.queuePlanningProject(channelKey, "apply");
|
|
93
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
94
|
+
|
|
95
|
+
assert.match(result.text ?? "", /Planning Ready/);
|
|
96
|
+
assert.equal(project?.status, "ready");
|
|
97
|
+
assert.match(project?.latestSummary ?? "", /Waiting for cs-plan in chat/);
|
|
98
|
+
assert.deepEqual(watcherManager.wakeCalls, []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("apply reports no new planning notes when the chat context is detached", async () => {
|
|
102
|
+
const harness = await createServiceHarness("clawspec-apply-detached-no-new-plan-");
|
|
103
|
+
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
104
|
+
const channelKey = "discord:apply-detached-no-new-plan:default:main";
|
|
105
|
+
|
|
106
|
+
await seedPlanningProject(stateStore, channelKey, {
|
|
107
|
+
workspacePath,
|
|
108
|
+
repoPath,
|
|
109
|
+
projectName: "demo-app",
|
|
110
|
+
changeName: "weather-plan",
|
|
111
|
+
changeDir,
|
|
112
|
+
phase: "tasks",
|
|
113
|
+
status: "ready",
|
|
114
|
+
planningDirty: true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await stateStore.updateProject(channelKey, (current) => ({
|
|
118
|
+
...current,
|
|
119
|
+
contextMode: "detached",
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
123
|
+
const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
|
|
124
|
+
await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, "weather-plan");
|
|
125
|
+
|
|
126
|
+
const result = await service.queuePlanningProject(channelKey, "apply");
|
|
127
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
128
|
+
|
|
129
|
+
assert.match(result.text ?? "", /No New Planning Notes/);
|
|
130
|
+
assert.equal(project?.planningJournal?.dirty, false);
|
|
131
|
+
assert.deepEqual(watcherManager.wakeCalls, []);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("cs-plan runs visible planning sync and writes a fresh snapshot", async () => {
|
|
135
|
+
const harness = await createServiceHarness("clawspec-visible-plan-");
|
|
136
|
+
const { service, stateStore, repoPath } = harness;
|
|
137
|
+
const channelKey = "discord:visible-plan:default:main";
|
|
138
|
+
const promptContext = {
|
|
139
|
+
trigger: "user",
|
|
140
|
+
channel: "discord",
|
|
141
|
+
channelId: "visible-plan",
|
|
142
|
+
accountId: "default",
|
|
143
|
+
conversationId: "main",
|
|
144
|
+
sessionKey: "agent:main:discord:channel:visible-plan",
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
await service.startProject(channelKey);
|
|
148
|
+
await service.useProject(channelKey, "demo-app");
|
|
149
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
150
|
+
await service.recordPlanningMessageFromContext(promptContext, "add another API endpoint");
|
|
151
|
+
|
|
152
|
+
const injected = await service.handleBeforePromptBuild(
|
|
153
|
+
{ prompt: "cs-plan", messages: [] },
|
|
154
|
+
promptContext,
|
|
155
|
+
);
|
|
156
|
+
const runningProject = await stateStore.getActiveProject(channelKey);
|
|
157
|
+
|
|
158
|
+
assert.match(injected?.prependContext ?? "", /ClawSpec planning sync is active for this turn/);
|
|
159
|
+
assert.equal(runningProject?.status, "planning");
|
|
160
|
+
assert.equal(runningProject?.phase, "planning_sync");
|
|
161
|
+
|
|
162
|
+
await service.handleAgentEnd(
|
|
163
|
+
{ messages: [], success: true, durationMs: 10 },
|
|
164
|
+
promptContext,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const finalized = await stateStore.getActiveProject(channelKey);
|
|
168
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
169
|
+
const snapshotExists = await pathExists(repoStatePaths.planningJournalSnapshotFile);
|
|
170
|
+
const snapshot = await readJsonFile<any>(repoStatePaths.planningJournalSnapshotFile, null);
|
|
171
|
+
|
|
172
|
+
assert.equal(finalized?.status, "ready");
|
|
173
|
+
assert.equal(finalized?.phase, "tasks");
|
|
174
|
+
assert.equal(finalized?.planningJournal?.dirty, false);
|
|
175
|
+
assert.equal(snapshotExists, true);
|
|
176
|
+
assert.equal(snapshot?.changeName, "demo-change");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("cs-plan clears stale execution control artifacts from earlier worker runs", async () => {
|
|
180
|
+
const harness = await createServiceHarness("clawspec-visible-plan-cleanup-");
|
|
181
|
+
const { service, stateStore, repoPath } = harness;
|
|
182
|
+
const channelKey = "discord:visible-plan-cleanup:default:main";
|
|
183
|
+
const promptContext = {
|
|
184
|
+
trigger: "user",
|
|
185
|
+
channel: "discord",
|
|
186
|
+
channelId: "visible-plan-cleanup",
|
|
187
|
+
accountId: "default",
|
|
188
|
+
conversationId: "main",
|
|
189
|
+
sessionKey: "agent:main:discord:channel:visible-plan-cleanup",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
await service.startProject(channelKey);
|
|
193
|
+
await service.useProject(channelKey, "demo-app");
|
|
194
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
195
|
+
await service.recordPlanningMessageFromContext(promptContext, "add another API endpoint");
|
|
196
|
+
|
|
197
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
198
|
+
await writeUtf8(repoStatePaths.executionControlFile, "{\"state\":\"running\"}\n");
|
|
199
|
+
await writeUtf8(repoStatePaths.executionResultFile, "{\"status\":\"running\"}\n");
|
|
200
|
+
await writeUtf8(repoStatePaths.workerProgressFile, "{\"kind\":\"task_start\"}\n");
|
|
201
|
+
|
|
202
|
+
await service.handleBeforePromptBuild(
|
|
203
|
+
{ prompt: "cs-plan", messages: [] },
|
|
204
|
+
promptContext,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await service.handleAgentEnd(
|
|
208
|
+
{ messages: [], success: true, durationMs: 10 },
|
|
209
|
+
promptContext,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
assert.equal(await pathExists(repoStatePaths.executionControlFile), false);
|
|
213
|
+
assert.equal(await pathExists(repoStatePaths.executionResultFile), false);
|
|
214
|
+
assert.equal(await pathExists(repoStatePaths.workerProgressFile), false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("ordinary planning discussion does not preload planning artifacts or propose skill", async () => {
|
|
218
|
+
const harness = await createServiceHarness("clawspec-discussion-guard-");
|
|
219
|
+
const { service } = harness;
|
|
220
|
+
const channelKey = "discord:discussion-guard:default:main";
|
|
221
|
+
const promptContext = {
|
|
222
|
+
trigger: "user",
|
|
223
|
+
channel: "discord",
|
|
224
|
+
channelId: "discussion-guard",
|
|
225
|
+
accountId: "default",
|
|
226
|
+
conversationId: "main",
|
|
227
|
+
sessionKey: "agent:main:discord:channel:discussion-guard",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
await service.startProject(channelKey);
|
|
231
|
+
await service.useProject(channelKey, "demo-app");
|
|
232
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
233
|
+
|
|
234
|
+
const injected = await service.handleBeforePromptBuild(
|
|
235
|
+
{ prompt: "再增加一个接口", messages: [] },
|
|
236
|
+
promptContext,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
assert.match(injected?.prependContext ?? "", /ClawSpec planning discussion mode is active/);
|
|
240
|
+
assert.doesNotMatch(injected?.prependContext ?? "", /openspec[\\/].*proposal\.md/);
|
|
241
|
+
assert.doesNotMatch(injected?.prependContext ?? "", /openspec[\\/].*design\.md/);
|
|
242
|
+
assert.doesNotMatch(injected?.prependContext ?? "", /openspec[\\/].*tasks\.md/);
|
|
243
|
+
assert.match(injected?.prependContext ?? "", /Do not say planning has started, queued, refreshed, synced, or completed/);
|
|
244
|
+
assert.match(injected?.prependContext ?? "", /explicitly tell the user that `cs-plan` is the next step before any further implementation/);
|
|
245
|
+
assert.match(injected?.prependContext ?? "", /do not say the next step is `cs-work`/);
|
|
246
|
+
assert.doesNotMatch(injected?.prependSystemContext ?? "", /openspec-propose/i);
|
|
247
|
+
});
|