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,140 @@
1
+ import path from "node:path";
2
+ import { ensureDir, pathExists, readJsonFile, writeJsonFile } from "../utils/fs.ts";
3
+ import { sameNormalizedPath } from "../utils/paths.ts";
4
+ import type { WorkspaceRecord, WorkspaceStateFile } from "../types.ts";
5
+
6
+ export class WorkspaceStore {
7
+ readonly filePath: string;
8
+ readonly defaultWorkspace: string;
9
+ private initPromise: Promise<void> | undefined;
10
+
11
+ constructor(filePath: string, defaultWorkspace: string) {
12
+ this.filePath = filePath;
13
+ this.defaultWorkspace = path.normalize(defaultWorkspace);
14
+ }
15
+
16
+ async initialize(): Promise<void> {
17
+ this.initPromise ??= this.doInitialize();
18
+ return this.initPromise;
19
+ }
20
+
21
+ private async doInitialize(): Promise<void> {
22
+ await ensureDir(path.dirname(this.filePath));
23
+ if (!(await pathExists(this.filePath))) {
24
+ await this.writeState({
25
+ version: 1,
26
+ currentWorkspace: this.defaultWorkspace,
27
+ currentWorkspaceByChannel: {},
28
+ workspaces: [{
29
+ path: this.defaultWorkspace,
30
+ lastUsedAt: new Date().toISOString(),
31
+ }],
32
+ });
33
+ }
34
+
35
+ const currentWorkspace = await this.getCurrentWorkspace();
36
+ await ensureDir(currentWorkspace);
37
+ }
38
+
39
+ async getCurrentWorkspace(channelKey?: string): Promise<string> {
40
+ const state = await this.readState();
41
+ if (channelKey) {
42
+ const channelWorkspace = state.currentWorkspaceByChannel?.[channelKey];
43
+ if (channelWorkspace) {
44
+ return path.normalize(channelWorkspace);
45
+ }
46
+ }
47
+ return path.normalize(state.currentWorkspace || this.defaultWorkspace);
48
+ }
49
+
50
+ async list(): Promise<WorkspaceRecord[]> {
51
+ const state = await this.readState();
52
+ return [...state.workspaces].sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
53
+ }
54
+
55
+ async useWorkspace(workspacePath: string, channelKey?: string): Promise<WorkspaceStateFile> {
56
+ const normalized = path.normalize(workspacePath);
57
+ await ensureDir(normalized);
58
+ const state = await this.readState();
59
+ const now = new Date().toISOString();
60
+ const existing = state.workspaces.find((entry) => sameNormalizedPath(entry.path, normalized));
61
+ const nextByChannel = { ...(state.currentWorkspaceByChannel ?? {}) };
62
+ if (channelKey) {
63
+ nextByChannel[channelKey] = normalized;
64
+ }
65
+
66
+ const next: WorkspaceStateFile = {
67
+ version: 1,
68
+ currentWorkspace: normalized,
69
+ currentWorkspaceByChannel: nextByChannel,
70
+ workspaces: [
71
+ ...state.workspaces.filter((entry) => !sameNormalizedPath(entry.path, normalized)),
72
+ {
73
+ path: normalized,
74
+ lastUsedAt: now,
75
+ },
76
+ ],
77
+ };
78
+
79
+ if (!existing && next.workspaces.length === 0) {
80
+ next.workspaces.push({ path: normalized, lastUsedAt: now });
81
+ }
82
+
83
+ await this.writeState(next);
84
+ return next;
85
+ }
86
+
87
+ private async readState(): Promise<WorkspaceStateFile> {
88
+ const fallback: WorkspaceStateFile = {
89
+ version: 1,
90
+ currentWorkspace: this.defaultWorkspace,
91
+ currentWorkspaceByChannel: {},
92
+ workspaces: [{
93
+ path: this.defaultWorkspace,
94
+ lastUsedAt: new Date(0).toISOString(),
95
+ }],
96
+ };
97
+ const raw = await readJsonFile<WorkspaceStateFile | (WorkspaceStateFile & { workspace?: string })>(
98
+ this.filePath,
99
+ fallback,
100
+ );
101
+
102
+ const currentWorkspace = path.normalize(
103
+ (raw as WorkspaceStateFile).currentWorkspace ?? (raw as { workspace?: string }).workspace ?? this.defaultWorkspace,
104
+ );
105
+ const currentWorkspaceByChannel = typeof (raw as WorkspaceStateFile).currentWorkspaceByChannel === "object"
106
+ && (raw as WorkspaceStateFile).currentWorkspaceByChannel
107
+ ? Object.fromEntries(
108
+ Object.entries((raw as WorkspaceStateFile).currentWorkspaceByChannel ?? {})
109
+ .filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string")
110
+ .map(([channelKey, workspacePath]) => [channelKey, path.normalize(workspacePath)]),
111
+ )
112
+ : {};
113
+ const workspaces = Array.isArray((raw as WorkspaceStateFile).workspaces)
114
+ ? (raw as WorkspaceStateFile).workspaces
115
+ .filter((entry): entry is WorkspaceRecord => typeof entry?.path === "string" && entry.path.trim().length > 0)
116
+ .map((entry) => ({
117
+ path: path.normalize(entry.path),
118
+ lastUsedAt: entry.lastUsedAt || new Date(0).toISOString(),
119
+ }))
120
+ : [];
121
+
122
+ if (!workspaces.some((entry) => sameNormalizedPath(entry.path, currentWorkspace))) {
123
+ workspaces.push({
124
+ path: currentWorkspace,
125
+ lastUsedAt: new Date().toISOString(),
126
+ });
127
+ }
128
+
129
+ return {
130
+ version: 1,
131
+ currentWorkspace,
132
+ currentWorkspaceByChannel,
133
+ workspaces,
134
+ };
135
+ }
136
+
137
+ private async writeState(state: WorkspaceStateFile): Promise<void> {
138
+ await writeJsonFile(this.filePath, state);
139
+ }
140
+ }
@@ -0,0 +1,234 @@
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 { chmod, mkdtemp, writeFile } from "node:fs/promises";
6
+ import { AcpWorkerClient } from "../src/acp/client.ts";
7
+
8
+ test("AcpWorkerClient tracks active worker lifecycle through acpx CLI", async () => {
9
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-acpx-client-"));
10
+ const fake = await createFakeAcpx(tempRoot);
11
+ const client = new AcpWorkerClient({
12
+ agentId: "codex",
13
+ logger: createLogger(),
14
+ command: fake.command,
15
+ env: fake.env,
16
+ });
17
+
18
+ const events: Array<{ type: string; text?: string }> = [];
19
+ const runPromise = client.runTurn({
20
+ sessionKey: "session-1",
21
+ cwd: tempRoot,
22
+ text: "fix tests",
23
+ onEvent: async (event) => {
24
+ events.push(event);
25
+ },
26
+ });
27
+
28
+ await waitFor(async () => {
29
+ const status = await client.getSessionStatus({
30
+ sessionKey: "session-1",
31
+ cwd: tempRoot,
32
+ agentId: "codex",
33
+ });
34
+ return status?.details?.status === "alive";
35
+ });
36
+
37
+ await runPromise;
38
+
39
+ const finalStatus = await client.getSessionStatus({
40
+ sessionKey: "session-1",
41
+ cwd: tempRoot,
42
+ agentId: "codex",
43
+ });
44
+
45
+ assert.equal(events.some((event) => event.type === "text_delta" && event.text?.includes("Working on fix tests")), true);
46
+ assert.match(finalStatus?.summary ?? "", /status=dead/);
47
+ });
48
+
49
+ async function createFakeAcpx(tempRoot: string): Promise<{ command: string; env: NodeJS.ProcessEnv }> {
50
+ const scriptPath = path.join(tempRoot, "fake-acpx.js");
51
+ const wrapperPath = path.join(tempRoot, process.platform === "win32" ? "fake-acpx.cmd" : "fake-acpx");
52
+
53
+ await writeFile(scriptPath, `
54
+ const fs = require("node:fs/promises");
55
+ const path = require("node:path");
56
+
57
+ const args = process.argv.slice(2);
58
+ const stateDir = process.env.FAKE_ACPX_STATE;
59
+
60
+ function consumeGlobals(argv) {
61
+ const out = [...argv];
62
+ const result = [];
63
+ while (out.length > 0) {
64
+ const head = out[0];
65
+ if (head === "--format" || head === "--cwd" || head === "--ttl") {
66
+ out.shift();
67
+ out.shift();
68
+ continue;
69
+ }
70
+ if (head === "--json-strict" || head === "--approve-all" || head === "--approve-reads" || head === "--deny-all") {
71
+ out.shift();
72
+ continue;
73
+ }
74
+ result.push(...out);
75
+ break;
76
+ }
77
+ return result;
78
+ }
79
+
80
+ function flagValue(argv, name) {
81
+ const index = argv.indexOf(name);
82
+ if (index >= 0 && index + 1 < argv.length) {
83
+ return argv[index + 1];
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ async function readStdin() {
89
+ const chunks = [];
90
+ for await (const chunk of process.stdin) {
91
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
92
+ }
93
+ return Buffer.concat(chunks).toString("utf8");
94
+ }
95
+
96
+ async function writeJsonLine(value) {
97
+ process.stdout.write(JSON.stringify(value) + "\\n");
98
+ }
99
+
100
+ async function main() {
101
+ if (args.includes("--version")) {
102
+ process.stdout.write("0.3.1\\n");
103
+ return;
104
+ }
105
+
106
+ const rest = consumeGlobals(args);
107
+ const agent = rest[0];
108
+ const verb = rest[1];
109
+ const tail = rest.slice(2);
110
+ const sessionName = flagValue(tail, "--session") || flagValue(tail, "--name") || tail[1];
111
+ const sessionFile = sessionName ? path.join(stateDir, sessionName + ".json") : "";
112
+ const runningFile = sessionName ? path.join(stateDir, sessionName + ".running") : "";
113
+
114
+ async function sessionExists() {
115
+ try {
116
+ await fs.access(sessionFile);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ async function runningExists() {
124
+ try {
125
+ await fs.access(runningFile);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ if (verb === "sessions" && tail[0] === "ensure") {
133
+ if (await sessionExists()) {
134
+ await writeJsonLine({ acpxRecordId: "record-" + sessionName, acpxSessionId: "backend-" + sessionName, agentSessionId: "agent-" + sessionName, agent });
135
+ return;
136
+ }
137
+ await writeJsonLine({ type: "error", code: "NO_SESSION", message: "missing session" });
138
+ return;
139
+ }
140
+
141
+ if (verb === "sessions" && tail[0] === "new") {
142
+ await fs.writeFile(sessionFile, JSON.stringify({ sessionName, agent }), "utf8");
143
+ await writeJsonLine({ acpxRecordId: "record-" + sessionName, acpxSessionId: "backend-" + sessionName, agentSessionId: "agent-" + sessionName, agent });
144
+ return;
145
+ }
146
+
147
+ if (verb === "status") {
148
+ if (!(await sessionExists())) {
149
+ await writeJsonLine({ type: "error", code: "NO_SESSION", message: "missing session" });
150
+ return;
151
+ }
152
+ await writeJsonLine({
153
+ status: await runningExists() ? "alive" : "dead",
154
+ acpxRecordId: "record-" + sessionName,
155
+ acpxSessionId: "backend-" + sessionName,
156
+ agentSessionId: "agent-" + sessionName,
157
+ pid: 1234,
158
+ });
159
+ return;
160
+ }
161
+
162
+ if (verb === "cancel") {
163
+ await fs.rm(runningFile, { force: true });
164
+ await writeJsonLine({ ok: true });
165
+ return;
166
+ }
167
+
168
+ if (verb === "prompt") {
169
+ const text = (await readStdin()).trim();
170
+ await fs.writeFile(runningFile, "running", "utf8");
171
+ process.on("SIGTERM", async () => {
172
+ await fs.rm(runningFile, { force: true });
173
+ process.exit(143);
174
+ });
175
+ await new Promise((resolve) => setTimeout(resolve, 60));
176
+ await writeJsonLine({ text: "Working on " + text });
177
+ await new Promise((resolve) => setTimeout(resolve, 80));
178
+ await fs.rm(runningFile, { force: true });
179
+ await writeJsonLine({ type: "done" });
180
+ return;
181
+ }
182
+
183
+ if (verb === "sessions" && tail[0] === "close") {
184
+ await fs.rm(sessionFile, { force: true });
185
+ await fs.rm(runningFile, { force: true });
186
+ await writeJsonLine({ ok: true });
187
+ return;
188
+ }
189
+
190
+ process.stderr.write("unexpected args: " + JSON.stringify(args));
191
+ process.exit(1);
192
+ }
193
+
194
+ main().catch((error) => {
195
+ process.stderr.write(String(error && error.stack ? error.stack : error));
196
+ process.exit(1);
197
+ });
198
+ `, "utf8");
199
+
200
+ if (process.platform === "win32") {
201
+ await writeFile(wrapperPath, `@echo off\r\nnode "${scriptPath}" %*\r\n`, "utf8");
202
+ } else {
203
+ await writeFile(wrapperPath, `#!/usr/bin/env bash\nnode "${scriptPath}" "$@"\n`, "utf8");
204
+ await chmod(wrapperPath, 0o755);
205
+ }
206
+
207
+ return {
208
+ command: wrapperPath,
209
+ env: {
210
+ ...process.env,
211
+ FAKE_ACPX_STATE: tempRoot,
212
+ },
213
+ };
214
+ }
215
+
216
+ function createLogger() {
217
+ return {
218
+ info() {},
219
+ warn() {},
220
+ error() {},
221
+ debug() {},
222
+ };
223
+ }
224
+
225
+ async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 2_000): Promise<void> {
226
+ const deadline = Date.now() + timeoutMs;
227
+ while (Date.now() < deadline) {
228
+ if (await predicate()) {
229
+ return;
230
+ }
231
+ await new Promise((resolve) => setTimeout(resolve, 25));
232
+ }
233
+ throw new Error("timed out waiting for condition");
234
+ }
@@ -0,0 +1,112 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "node:path";
4
+ import {
5
+ ACPX_EXPECTED_VERSION,
6
+ ACPX_PACKAGE_NAME,
7
+ ensureAcpxCli,
8
+ } from "../src/dependencies/acpx.ts";
9
+
10
+ const ROOT_PREFIX = process.platform === "win32" ? "C:\\clawspec-test" : "/tmp/clawspec-test";
11
+ const OPENCLAW_PREFIX = process.platform === "win32" ? "C:\\openclaw-test" : "/opt/openclaw";
12
+
13
+ const LOCAL_COMMAND = path.join(
14
+ ROOT_PREFIX,
15
+ "node_modules",
16
+ ".bin",
17
+ process.platform === "win32" ? "acpx.cmd" : "acpx",
18
+ );
19
+ const BUILTIN_COMMAND = path.join(
20
+ OPENCLAW_PREFIX,
21
+ "dist",
22
+ "extensions",
23
+ "acpx",
24
+ "node_modules",
25
+ ".bin",
26
+ process.platform === "win32" ? "acpx.cmd" : "acpx",
27
+ );
28
+ const OPENCLAW_RUNTIME_ENTRYPOINT = path.join(
29
+ OPENCLAW_PREFIX,
30
+ "dist",
31
+ "index.js",
32
+ );
33
+
34
+ test("ensureAcpxCli uses the global acpx command when available", async () => {
35
+ const calls: Array<{ command: string; args: string[] }> = [];
36
+ const result = await ensureAcpxCli({
37
+ pluginRoot: ROOT_PREFIX,
38
+ runner: async ({ command, args }) => {
39
+ calls.push({ command, args });
40
+ if (command === LOCAL_COMMAND) {
41
+ return { code: 1, stdout: "", stderr: "not found" };
42
+ }
43
+ if (command === "acpx") {
44
+ return { code: 0, stdout: `${ACPX_EXPECTED_VERSION}\n`, stderr: "" };
45
+ }
46
+ return { code: 1, stdout: "", stderr: "unexpected command" };
47
+ },
48
+ });
49
+
50
+ assert.equal(result.source, "global");
51
+ assert.equal(result.version, ACPX_EXPECTED_VERSION);
52
+ assert.equal(calls.some((call) => call.command === "npm"), false);
53
+ });
54
+
55
+ test("ensureAcpxCli prefers the OpenClaw builtin acpx over an incompatible PATH acpx", async () => {
56
+ const calls: Array<{ command: string; args: string[] }> = [];
57
+ const result = await ensureAcpxCli({
58
+ pluginRoot: ROOT_PREFIX,
59
+ runtimeEntrypoint: OPENCLAW_RUNTIME_ENTRYPOINT,
60
+ runner: async ({ command, args }) => {
61
+ calls.push({ command, args });
62
+ if (command === LOCAL_COMMAND) {
63
+ return { code: 1, stdout: "", stderr: "not found" };
64
+ }
65
+ if (command === BUILTIN_COMMAND) {
66
+ return { code: 0, stdout: `${ACPX_EXPECTED_VERSION}\n`, stderr: "" };
67
+ }
68
+ if (command === "acpx") {
69
+ return { code: 0, stdout: "0.1.15\n", stderr: "" };
70
+ }
71
+ return { code: 1, stdout: "", stderr: "unexpected command" };
72
+ },
73
+ });
74
+
75
+ assert.equal(result.source, "builtin");
76
+ assert.equal(result.version, ACPX_EXPECTED_VERSION);
77
+ assert.equal(result.command, BUILTIN_COMMAND);
78
+ assert.equal(calls.some((call) => call.command === "npm"), false);
79
+ });
80
+
81
+ test("ensureAcpxCli installs a plugin-local acpx when none is available", async () => {
82
+ const calls: Array<{ command: string; args: string[] }> = [];
83
+ let localCheckCount = 0;
84
+
85
+ const result = await ensureAcpxCli({
86
+ pluginRoot: ROOT_PREFIX,
87
+ runner: async ({ command, args }) => {
88
+ calls.push({ command, args });
89
+ if (command === LOCAL_COMMAND) {
90
+ localCheckCount += 1;
91
+ if (localCheckCount === 1) {
92
+ return { code: 1, stdout: "", stderr: "not found" };
93
+ }
94
+ return { code: 0, stdout: `${ACPX_EXPECTED_VERSION}\n`, stderr: "" };
95
+ }
96
+ if (command === "acpx") {
97
+ return { code: 1, stdout: "", stderr: "not found" };
98
+ }
99
+ if (command === "npm") {
100
+ return { code: 0, stdout: "installed\n", stderr: "" };
101
+ }
102
+ return { code: 1, stdout: "", stderr: "unexpected command" };
103
+ },
104
+ });
105
+
106
+ assert.equal(result.source, "local");
107
+ assert.equal(result.version, ACPX_EXPECTED_VERSION);
108
+ assert.equal(
109
+ calls.some((call) => call.command === "npm" && call.args.includes(`${ACPX_PACKAGE_NAME}@${ACPX_EXPECTED_VERSION}`)),
110
+ true,
111
+ );
112
+ });
@@ -0,0 +1,136 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { PlanningJournalStore } from "../src/planning/journal.ts";
4
+ import { getRepoStatePaths } from "../src/utils/paths.ts";
5
+ import { createServiceHarness } from "./helpers/harness.ts";
6
+
7
+ function createPromptContext(channelId: string) {
8
+ return {
9
+ trigger: "user",
10
+ channel: "discord",
11
+ channelId,
12
+ accountId: "default",
13
+ conversationId: "main",
14
+ sessionKey: `agent:main:discord:channel:${channelId}`,
15
+ };
16
+ }
17
+
18
+ test("assistant planning suggestions are appended to the journal for attached discussion turns", async () => {
19
+ const harness = await createServiceHarness("clawspec-assistant-journal-");
20
+ const { service, stateStore, repoPath } = harness;
21
+ const channelKey = "discord:assistant-journal:default:main";
22
+ const promptContext = createPromptContext("assistant-journal");
23
+ const userPrompt = "Add JWT auth, refresh tokens, and forgot-password support.";
24
+
25
+ await service.startProject(channelKey);
26
+ await service.useProject(channelKey, "demo-app");
27
+ await service.proposalProject(channelKey, "demo-change Demo change");
28
+ await service.recordPlanningMessageFromContext(promptContext, userPrompt);
29
+ await service.handleBeforePromptBuild(
30
+ { prompt: userPrompt, messages: [] },
31
+ promptContext,
32
+ );
33
+
34
+ await service.handleAgentEnd(
35
+ {
36
+ success: true,
37
+ messages: [
38
+ { role: "user", content: userPrompt },
39
+ {
40
+ role: "assistant",
41
+ content: [
42
+ {
43
+ type: "text",
44
+ text: "We should keep short-lived access tokens, add a refresh endpoint, and split forgot-password into request and confirm steps.",
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ },
50
+ promptContext,
51
+ );
52
+
53
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
54
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
55
+ const entries = await journalStore.list("demo-change");
56
+ const project = await stateStore.getActiveProject(channelKey);
57
+
58
+ assert.equal(entries.length, 2);
59
+ assert.equal(entries[0]?.role, "user");
60
+ assert.equal(entries[1]?.role, "assistant");
61
+ assert.match(entries[1]?.text ?? "", /refresh endpoint/i);
62
+ assert.equal(project?.planningJournal?.dirty, true);
63
+ assert.equal(project?.planningJournal?.entryCount, 2);
64
+ });
65
+
66
+ test("passive assistant control replies are not appended to the planning journal", async () => {
67
+ const harness = await createServiceHarness("clawspec-passive-assistant-journal-");
68
+ const { service, repoPath } = harness;
69
+ const channelKey = "discord:passive-assistant-journal:default:main";
70
+ const promptContext = createPromptContext("passive-assistant-journal");
71
+ const userPrompt = "Add a help endpoint for the API catalog.";
72
+
73
+ await service.startProject(channelKey);
74
+ await service.useProject(channelKey, "demo-app");
75
+ await service.proposalProject(channelKey, "demo-change Demo change");
76
+ await service.recordPlanningMessageFromContext(promptContext, userPrompt);
77
+ await service.handleBeforePromptBuild(
78
+ { prompt: userPrompt, messages: [] },
79
+ promptContext,
80
+ );
81
+
82
+ await service.handleAgentEnd(
83
+ {
84
+ success: true,
85
+ messages: [
86
+ { role: "user", content: userPrompt },
87
+ {
88
+ role: "assistant",
89
+ content: "Continue describing requirements if needed.\nNext step: run `cs-plan`.",
90
+ },
91
+ ],
92
+ },
93
+ promptContext,
94
+ );
95
+
96
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
97
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
98
+ const entries = await journalStore.list("demo-change");
99
+
100
+ assert.equal(entries.length, 1);
101
+ assert.equal(entries[0]?.role, "user");
102
+ });
103
+
104
+ test("assistant discussion replies are not journaled after the chat is detached", async () => {
105
+ const harness = await createServiceHarness("clawspec-detached-assistant-journal-");
106
+ const { service, repoPath } = harness;
107
+ const channelKey = "discord:detached-assistant-journal:default:main";
108
+ const promptContext = createPromptContext("detached-assistant-journal");
109
+ const userPrompt = "Add a city weather endpoint.";
110
+
111
+ await service.startProject(channelKey);
112
+ await service.useProject(channelKey, "demo-app");
113
+ await service.proposalProject(channelKey, "demo-change Demo change");
114
+ await service.handleBeforePromptBuild(
115
+ { prompt: userPrompt, messages: [] },
116
+ promptContext,
117
+ );
118
+ await service.detachProject(channelKey);
119
+
120
+ await service.handleAgentEnd(
121
+ {
122
+ success: true,
123
+ messages: [
124
+ { role: "user", content: userPrompt },
125
+ { role: "assistant", content: "We can resolve the city name first, then fetch weather details." },
126
+ ],
127
+ },
128
+ promptContext,
129
+ );
130
+
131
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
132
+ const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
133
+ const entries = await journalStore.list("demo-change");
134
+
135
+ assert.equal(entries.length, 0);
136
+ });
@@ -0,0 +1,23 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildHelpText } from "../src/orchestrator/helpers.ts";
4
+
5
+ test("help text only advertises the clawspec command surface", () => {
6
+ const help = buildHelpText();
7
+
8
+ assert.match(help, /\/clawspec workspace/);
9
+ assert.match(help, /\/clawspec proposal <change-name> \[description\]/);
10
+ assert.match(help, /\/clawspec continue/);
11
+ assert.match(help, /\/clawspec detach/);
12
+ assert.match(help, /cs-detach/);
13
+ assert.match(help, /legacy aliases/);
14
+ assert.match(help, /cs-plan/);
15
+ assert.match(help, /cs-work/);
16
+
17
+ assert.doesNotMatch(help, /`\/project\b/);
18
+ assert.doesNotMatch(help, /\/clawspec apply\b/);
19
+ assert.doesNotMatch(help, /cs-proposal\b/);
20
+ assert.doesNotMatch(help, /cs-propose\b/);
21
+ assert.doesNotMatch(help, /cs-pop\b/);
22
+ assert.doesNotMatch(help, /cs-push\b/);
23
+ });