clawspec 1.0.4 → 1.0.5
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 +16 -3
- package/README.zh-CN.md +16 -3
- package/package.json +2 -2
- package/src/control/keywords.ts +19 -12
- package/src/dependencies/openspec.ts +18 -6
- package/src/index.ts +47 -0
- package/src/orchestrator/service.ts +157 -4
- package/src/utils/paths.ts +2 -1
- package/src/worker/prompts.ts +1 -1
- package/test/assistant-journal.test.ts +32 -0
- package/test/keywords.test.ts +12 -0
- package/test/openspec-dependency.test.ts +15 -14
- package/test/paths-utils.test.ts +30 -0
- package/test/queue-planning.test.ts +2 -0
- package/test/use-project.test.ts +48 -0
package/README.md
CHANGED
|
@@ -224,15 +224,28 @@ If your host disables plugin hooks globally, keyword-based workflow will not wor
|
|
|
224
224
|
|
|
225
225
|
### 1. Install the plugin
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
You can install ClawSpec in three common ways:
|
|
228
|
+
|
|
229
|
+
Option A: OpenClaw plugin installer (recommended)
|
|
228
230
|
|
|
229
231
|
```powershell
|
|
230
232
|
openclaw plugins install clawspec@latest
|
|
231
233
|
```
|
|
232
234
|
|
|
233
|
-
|
|
235
|
+
Option B: ClawHub CLI installer
|
|
236
|
+
|
|
237
|
+
```powershell
|
|
238
|
+
npx clawhub@latest install clawspec
|
|
239
|
+
```
|
|
234
240
|
|
|
235
|
-
|
|
241
|
+
Option C: npm package tarball (manual fallback)
|
|
242
|
+
|
|
243
|
+
```powershell
|
|
244
|
+
$pkg = npm pack clawspec@latest
|
|
245
|
+
openclaw plugins install $pkg
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`@latest` always resolves to the newest published ClawSpec release on npm.
|
|
236
249
|
|
|
237
250
|
If you want an unreleased commit before npm publish, clone the repository and install from the local checkout or a downloaded `.tgz` archive instead.
|
|
238
251
|
|
package/README.zh-CN.md
CHANGED
|
@@ -229,15 +229,28 @@ ClawSpec 依赖这几个 OpenClaw hook:
|
|
|
229
229
|
|
|
230
230
|
### 1. 安装插件
|
|
231
231
|
|
|
232
|
-
|
|
232
|
+
常见安装方式有三种:
|
|
233
|
+
|
|
234
|
+
方式 A:通过 OpenClaw 插件安装器(推荐)
|
|
233
235
|
|
|
234
236
|
```powershell
|
|
235
237
|
openclaw plugins install clawspec@latest
|
|
236
238
|
```
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
方式 B:通过 ClawHub CLI 安装
|
|
241
|
+
|
|
242
|
+
```powershell
|
|
243
|
+
npx clawhub@latest install clawspec
|
|
244
|
+
```
|
|
239
245
|
|
|
240
|
-
|
|
246
|
+
方式 C:通过 npm 包手工安装(兜底)
|
|
247
|
+
|
|
248
|
+
```powershell
|
|
249
|
+
$pkg = npm pack clawspec@latest
|
|
250
|
+
openclaw plugins install $pkg
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`@latest` 会始终解析到 npm 上最新发布的 ClawSpec 版本。
|
|
241
254
|
|
|
242
255
|
如果你要安装一个还没发布到 npm 的提交,请改用本地 checkout 或下载好的 `.tgz` 包安装。
|
|
243
256
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawspec",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
|
|
6
6
|
"keywords": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"check": "node --experimental-strip-types -e \"import('./index.ts')\"",
|
|
48
48
|
"test": "node --experimental-strip-types --test --test-reporter spec test/*.test.ts",
|
|
49
|
-
"test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-io-helper.test.ts test/worker-skills.test.ts",
|
|
49
|
+
"test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/paths-utils.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-io-helper.test.ts test/worker-skills.test.ts",
|
|
50
50
|
"test:slow": "node --experimental-strip-types --test --test-reporter spec test/watcher-planning.test.ts test/watcher-work.test.ts test/recovery.test.ts"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
package/src/control/keywords.ts
CHANGED
|
@@ -29,20 +29,21 @@ const COMMAND_ALIASES: Record<string, ClawSpecKeywordKind> = {
|
|
|
29
29
|
|
|
30
30
|
export function parseClawSpecKeyword(text: string): ClawSpecKeywordIntent | null {
|
|
31
31
|
const trimmed = text.trim();
|
|
32
|
-
if (!trimmed
|
|
32
|
+
if (!trimmed) {
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const firstWhitespace = trimmed.search(/\s/);
|
|
37
37
|
const rawCommand = firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace);
|
|
38
|
-
const
|
|
38
|
+
const normalizedCommand = normalizeKeywordCommand(rawCommand);
|
|
39
|
+
const kind = COMMAND_ALIASES[normalizedCommand];
|
|
39
40
|
if (!kind) {
|
|
40
41
|
return null;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
return {
|
|
44
45
|
kind,
|
|
45
|
-
command:
|
|
46
|
+
command: normalizedCommand,
|
|
46
47
|
args: firstWhitespace === -1 ? "" : trimmed.slice(firstWhitespace + 1).trim(),
|
|
47
48
|
raw: trimmed,
|
|
48
49
|
};
|
|
@@ -52,21 +53,27 @@ export function isClawSpecKeywordText(text: string): boolean {
|
|
|
52
53
|
return parseClawSpecKeyword(text) !== null;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const EMBEDDED_KEYWORD_PATTERN = new RegExp(
|
|
56
|
-
`(?:^|\\r?\\n)\\s*(cs-(?:${Object.keys(COMMAND_ALIASES).map((k) => k.slice(3)).join("|")})(?:[^\\S\\r\\n]+[^\\r\\n]+)?)\\s*(?=\\r?\\n|$)`,
|
|
57
|
-
"i",
|
|
58
|
-
);
|
|
59
|
-
|
|
60
56
|
export function extractEmbeddedClawSpecKeyword(text: string): ClawSpecKeywordIntent | null {
|
|
61
57
|
const direct = parseClawSpecKeyword(text);
|
|
62
58
|
if (direct) {
|
|
63
59
|
return direct;
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
for (const line of text.split(/\r?\n/)) {
|
|
63
|
+
const parsed = parseClawSpecKeyword(line.trim());
|
|
64
|
+
if (parsed) {
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
69
67
|
}
|
|
70
68
|
|
|
71
|
-
return
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeKeywordCommand(rawCommand: string): string {
|
|
73
|
+
return rawCommand
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/^`+|`+$/g, "")
|
|
76
|
+
.replace(/^\/+/, "")
|
|
77
|
+
.replace(/[。!?!?,,;;::]+$/u, "")
|
|
78
|
+
.toLowerCase();
|
|
72
79
|
}
|
|
@@ -70,22 +70,26 @@ export async function ensureOpenSpecCli(
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
options.logger?.warn?.(
|
|
73
|
-
`[clawspec] openspec CLI not
|
|
73
|
+
`[clawspec] openspec CLI not ready (${globalCheck.message}); installing plugin-local ${OPENSPEC_PACKAGE_NAME}`,
|
|
74
74
|
);
|
|
75
75
|
|
|
76
76
|
const install = await runner({
|
|
77
77
|
command: "npm",
|
|
78
|
-
args: [
|
|
78
|
+
args: [
|
|
79
|
+
"install",
|
|
80
|
+
"--omit=dev",
|
|
81
|
+
"--no-save",
|
|
82
|
+
"--package-lock=false",
|
|
83
|
+
OPENSPEC_PACKAGE_NAME,
|
|
84
|
+
],
|
|
79
85
|
cwd: options.pluginRoot,
|
|
80
86
|
env,
|
|
81
87
|
});
|
|
82
88
|
if (install.error || (install.code ?? 0) !== 0) {
|
|
83
89
|
if (isMissingCommandResult(install, "npm")) {
|
|
84
|
-
throw new Error("npm is
|
|
90
|
+
throw new Error(buildOpenSpecInstallMessage("npm is not available on PATH"));
|
|
85
91
|
}
|
|
86
|
-
throw new Error(
|
|
87
|
-
`failed to install plugin-local openspec: ${describeCommandFailure(install, "npm install")}`,
|
|
88
|
-
);
|
|
92
|
+
throw new Error(`failed to install plugin-local openspec: ${describeCommandFailure(install, "npm install")}`);
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
const postcheck = await checkOpenSpecVersion(runner, {
|
|
@@ -105,6 +109,14 @@ export async function ensureOpenSpecCli(
|
|
|
105
109
|
};
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
export function buildOpenSpecInstallMessage(reason: string): string {
|
|
113
|
+
return [
|
|
114
|
+
`OpenSpec CLI is required but not available (${reason}).`,
|
|
115
|
+
"Install OpenSpec, then retry your command:",
|
|
116
|
+
"`npm install -g @fission-ai/openspec`",
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
108
120
|
async function checkOpenSpecVersion(
|
|
109
121
|
runner: CommandRunner,
|
|
110
122
|
params: {
|
package/src/index.ts
CHANGED
|
@@ -142,6 +142,20 @@ const plugin = {
|
|
|
142
142
|
text: "ClawSpec is still bootstrapping dependencies. Try again in a moment.",
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
|
+
const subcommand = parseSubcommand(ctx.args);
|
|
146
|
+
if (requiresOpenSpec(subcommand)) {
|
|
147
|
+
try {
|
|
148
|
+
await ensureOpenSpecCli({
|
|
149
|
+
pluginRoot: PLUGIN_ROOT,
|
|
150
|
+
logger: api.logger,
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
text: error instanceof Error ? error.message : String(error),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
145
159
|
return service.handleProjectCommand(ctx);
|
|
146
160
|
},
|
|
147
161
|
});
|
|
@@ -157,6 +171,20 @@ const plugin = {
|
|
|
157
171
|
accountId: ctx.accountId,
|
|
158
172
|
conversationId: ctx.conversationId,
|
|
159
173
|
sessionKey: (ctx as { sessionKey?: string }).sessionKey,
|
|
174
|
+
from: event.from,
|
|
175
|
+
metadata: event.metadata,
|
|
176
|
+
}, event.content);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
api.on("message_sent", async (event, ctx) => {
|
|
180
|
+
await stateStore.initialize();
|
|
181
|
+
if (!service || !event.success) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
service.recordOutboundMessageFromContext({
|
|
185
|
+
channelId: ctx.channelId,
|
|
186
|
+
accountId: ctx.accountId,
|
|
187
|
+
conversationId: ctx.conversationId,
|
|
160
188
|
}, event.content);
|
|
161
189
|
});
|
|
162
190
|
|
|
@@ -179,3 +207,22 @@ const plugin = {
|
|
|
179
207
|
};
|
|
180
208
|
|
|
181
209
|
export default plugin;
|
|
210
|
+
|
|
211
|
+
function parseSubcommand(args: string | undefined): string {
|
|
212
|
+
const trimmed = (args ?? "").trim();
|
|
213
|
+
if (!trimmed) {
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
const [first] = trimmed.split(/\s+/);
|
|
217
|
+
return (first ?? "").toLowerCase();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function requiresOpenSpec(subcommand: string): boolean {
|
|
221
|
+
return [
|
|
222
|
+
"use",
|
|
223
|
+
"proposal",
|
|
224
|
+
"continue",
|
|
225
|
+
"status",
|
|
226
|
+
"archive",
|
|
227
|
+
].includes(subcommand);
|
|
228
|
+
}
|
|
@@ -28,7 +28,11 @@ import type {
|
|
|
28
28
|
TaskCountSummary,
|
|
29
29
|
} from "../types.ts";
|
|
30
30
|
import { splitSubcommand, tokenizeArgs } from "../utils/args.ts";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
buildChannelKeyFromCommand,
|
|
33
|
+
buildLegacyChannelKeyFromCommand,
|
|
34
|
+
parseChannelKey,
|
|
35
|
+
} from "../utils/channel-key.ts";
|
|
32
36
|
import {
|
|
33
37
|
appendUtf8,
|
|
34
38
|
directoryExists,
|
|
@@ -955,7 +959,11 @@ export class ClawSpecService {
|
|
|
955
959
|
async workspaceProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
|
|
956
960
|
const currentWorkspace = await this.workspaceStore.getCurrentWorkspace(channelKey);
|
|
957
961
|
const project = await this.ensureSessionProject(channelKey, currentWorkspace);
|
|
958
|
-
const
|
|
962
|
+
const parsedArg = this.parseSinglePathArgument(rawArgs, "Usage: `/clawspec workspace <path>`.\nIf the path contains spaces, wrap it in quotes.");
|
|
963
|
+
if ("error" in parsedArg) {
|
|
964
|
+
return errorReply(parsedArg.error);
|
|
965
|
+
}
|
|
966
|
+
const requested = parsedArg.value;
|
|
959
967
|
|
|
960
968
|
if (!requested) {
|
|
961
969
|
return okReply(await this.buildWorkspaceText(project));
|
|
@@ -1003,7 +1011,11 @@ export class ClawSpecService {
|
|
|
1003
1011
|
async useProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
|
|
1004
1012
|
const workspacePath = await this.workspaceStore.getCurrentWorkspace(channelKey);
|
|
1005
1013
|
const project = await this.ensureSessionProject(channelKey, workspacePath);
|
|
1006
|
-
const
|
|
1014
|
+
const parsedArg = this.parseSinglePathArgument(rawArgs, "Usage: `/clawspec use <project-name>`.\nIf the project path contains spaces, wrap it in quotes.");
|
|
1015
|
+
if ("error" in parsedArg) {
|
|
1016
|
+
return errorReply(parsedArg.error);
|
|
1017
|
+
}
|
|
1018
|
+
const input = parsedArg.value;
|
|
1007
1019
|
|
|
1008
1020
|
if (!input) {
|
|
1009
1021
|
return okReply(await this.buildWorkspaceText(project));
|
|
@@ -1854,6 +1866,7 @@ export class ClawSpecService {
|
|
|
1854
1866
|
channelId?: string;
|
|
1855
1867
|
accountId?: string;
|
|
1856
1868
|
conversationId?: string;
|
|
1869
|
+
from?: string;
|
|
1857
1870
|
metadata?: Record<string, unknown>;
|
|
1858
1871
|
}, text: string): boolean {
|
|
1859
1872
|
const normalized = sanitizePlanningMessageText(text).trim();
|
|
@@ -1862,6 +1875,10 @@ export class ClawSpecService {
|
|
|
1862
1875
|
}
|
|
1863
1876
|
|
|
1864
1877
|
const metadata = params.metadata;
|
|
1878
|
+
const normalizedFrom = params.from?.trim().toLowerCase();
|
|
1879
|
+
if (normalizedFrom && ["assistant", "bot", "system", "plugin", "agent", "tool", "worker"].includes(normalizedFrom)) {
|
|
1880
|
+
return true;
|
|
1881
|
+
}
|
|
1865
1882
|
const selfFlags = [
|
|
1866
1883
|
metadata?.fromSelf,
|
|
1867
1884
|
metadata?.isSelf,
|
|
@@ -1874,6 +1891,20 @@ export class ClawSpecService {
|
|
|
1874
1891
|
return true;
|
|
1875
1892
|
}
|
|
1876
1893
|
|
|
1894
|
+
const metadataRoles = [
|
|
1895
|
+
metadata?.role,
|
|
1896
|
+
metadata?.senderRole,
|
|
1897
|
+
metadata?.authorRole,
|
|
1898
|
+
metadata?.messageRole,
|
|
1899
|
+
metadata?.sourceRole,
|
|
1900
|
+
metadata?.actorRole,
|
|
1901
|
+
]
|
|
1902
|
+
.filter((value): value is string => typeof value === "string")
|
|
1903
|
+
.map((value) => value.trim().toLowerCase());
|
|
1904
|
+
if (metadataRoles.some((value) => ["assistant", "bot", "system", "plugin", "agent", "tool", "worker"].includes(value))) {
|
|
1905
|
+
return true;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1877
1908
|
const scopeKey = this.buildMessageScopeKey(params);
|
|
1878
1909
|
const now = Date.now();
|
|
1879
1910
|
const entries = (this.recentOutboundMessages.get(scopeKey) ?? [])
|
|
@@ -1897,6 +1928,118 @@ export class ClawSpecService {
|
|
|
1897
1928
|
].join(":");
|
|
1898
1929
|
}
|
|
1899
1930
|
|
|
1931
|
+
private parseSinglePathArgument(
|
|
1932
|
+
rawArgs: string,
|
|
1933
|
+
usageMessage: string,
|
|
1934
|
+
): { value: string } | { error: string } {
|
|
1935
|
+
const trimmed = rawArgs.trim();
|
|
1936
|
+
if (!trimmed) {
|
|
1937
|
+
return { value: "" };
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const firstChar = trimmed[0];
|
|
1941
|
+
if (firstChar === "\"" || firstChar === "'") {
|
|
1942
|
+
if (trimmed.length < 2 || !trimmed.endsWith(firstChar)) {
|
|
1943
|
+
return { error: "Unterminated quoted argument." };
|
|
1944
|
+
}
|
|
1945
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
1946
|
+
return { value: inner };
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
if (/\s/.test(trimmed)) {
|
|
1950
|
+
return { error: usageMessage };
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
return { value: trimmed };
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
private async maybeSendPlanningNextStepNotice(project: ProjectState, event: AgentEndEvent): Promise<void> {
|
|
1957
|
+
if (!project.changeName) {
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
const latestAssistantText = sanitizePlanningMessageText(extractLatestMessageTextByRole(event.messages, "assistant") ?? "").toLowerCase();
|
|
1962
|
+
const alreadyContainsNextStep = latestAssistantText.includes("cs-work")
|
|
1963
|
+
|| latestAssistantText.includes("/clawspec archive")
|
|
1964
|
+
|| latestAssistantText.includes("run `cs-plan`");
|
|
1965
|
+
if (alreadyContainsNextStep) {
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
const projectLabel = project.projectName ?? "project";
|
|
1970
|
+
if (project.status === "ready" && project.phase === "tasks") {
|
|
1971
|
+
await this.sendChannelUpdate(
|
|
1972
|
+
project.channelKey,
|
|
1973
|
+
`✅ ${projectLabel}-${project.changeName} Planning ready. Next: run \`cs-work\` to start implementation.`,
|
|
1974
|
+
);
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
if (project.status === "done") {
|
|
1979
|
+
await this.sendChannelUpdate(
|
|
1980
|
+
project.channelKey,
|
|
1981
|
+
`🏁 ${projectLabel}-${project.changeName} Planning complete and tasks are already done. Next: use \`/clawspec archive\`.`,
|
|
1982
|
+
);
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
if (project.status === "blocked") {
|
|
1987
|
+
await this.sendChannelUpdate(
|
|
1988
|
+
project.channelKey,
|
|
1989
|
+
`⚠ ${projectLabel}-${project.changeName} Planning blocked. Next: review blockers, then run \`cs-plan\` again.`,
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
private async sendChannelUpdate(channelKey: string, text: string): Promise<void> {
|
|
1995
|
+
const runtime = (this.api as { runtime?: OpenClawPluginApi["runtime"] }).runtime;
|
|
1996
|
+
if (!runtime?.channel) {
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
const route = parseChannelKey(channelKey);
|
|
2001
|
+
const accountId = route.accountId && route.accountId !== "default" ? route.accountId : undefined;
|
|
2002
|
+
|
|
2003
|
+
try {
|
|
2004
|
+
switch (route.channel) {
|
|
2005
|
+
case "discord":
|
|
2006
|
+
await runtime.channel.discord.sendMessageDiscord(`channel:${route.channelId}`, text, {
|
|
2007
|
+
cfg: this.api.config,
|
|
2008
|
+
accountId,
|
|
2009
|
+
silent: true,
|
|
2010
|
+
});
|
|
2011
|
+
return;
|
|
2012
|
+
case "telegram":
|
|
2013
|
+
await runtime.channel.telegram.sendMessageTelegram(route.channelId, text, {
|
|
2014
|
+
cfg: this.api.config,
|
|
2015
|
+
accountId,
|
|
2016
|
+
silent: true,
|
|
2017
|
+
messageThreadId: parseOptionalNumber(route.conversationId),
|
|
2018
|
+
});
|
|
2019
|
+
return;
|
|
2020
|
+
case "slack":
|
|
2021
|
+
await runtime.channel.slack.sendMessageSlack(route.channelId, text, {
|
|
2022
|
+
cfg: this.api.config,
|
|
2023
|
+
accountId,
|
|
2024
|
+
threadTs: route.conversationId !== "main" ? route.conversationId : undefined,
|
|
2025
|
+
});
|
|
2026
|
+
return;
|
|
2027
|
+
case "signal":
|
|
2028
|
+
await runtime.channel.signal.sendMessageSignal(route.channelId, text, {
|
|
2029
|
+
cfg: this.api.config,
|
|
2030
|
+
accountId,
|
|
2031
|
+
});
|
|
2032
|
+
return;
|
|
2033
|
+
default:
|
|
2034
|
+
this.logger.info(`[clawspec] planning notice (${route.channel} ${route.channelId}): ${text}`);
|
|
2035
|
+
}
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
this.logger.warn(
|
|
2038
|
+
`[clawspec] failed to send planning notice to ${channelKey}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1900
2043
|
private async ensureSessionProject(channelKey: string, workspacePath: string): Promise<ProjectState> {
|
|
1901
2044
|
const existing = await this.stateStore.getActiveProject(channelKey);
|
|
1902
2045
|
if (existing) {
|
|
@@ -2200,7 +2343,7 @@ export class ClawSpecService {
|
|
|
2200
2343
|
}
|
|
2201
2344
|
await this.writeLatestSummary(repoStatePaths, latestSummary);
|
|
2202
2345
|
|
|
2203
|
-
await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
2346
|
+
const finalized = await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
2204
2347
|
...current,
|
|
2205
2348
|
status,
|
|
2206
2349
|
phase,
|
|
@@ -2219,6 +2362,8 @@ export class ClawSpecService {
|
|
|
2219
2362
|
lastSyncedAt,
|
|
2220
2363
|
},
|
|
2221
2364
|
}));
|
|
2365
|
+
|
|
2366
|
+
await this.maybeSendPlanningNextStepNotice(finalized, event);
|
|
2222
2367
|
}
|
|
2223
2368
|
|
|
2224
2369
|
private resolvePostRunStatus(
|
|
@@ -3008,6 +3153,14 @@ function isWorkflowControlLine(line: string): boolean {
|
|
|
3008
3153
|
|| lower === "`cs-work` is not available yet.";
|
|
3009
3154
|
}
|
|
3010
3155
|
|
|
3156
|
+
function parseOptionalNumber(value: string): number | undefined {
|
|
3157
|
+
if (!value || value === "main") {
|
|
3158
|
+
return undefined;
|
|
3159
|
+
}
|
|
3160
|
+
const parsed = Number(value);
|
|
3161
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3011
3164
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
3012
3165
|
return typeof value === "object" && value !== null;
|
|
3013
3166
|
}
|
package/src/utils/paths.ts
CHANGED
|
@@ -40,7 +40,8 @@ export function expandHomeDir(input: string): string {
|
|
|
40
40
|
|
|
41
41
|
export function resolveUserPath(input: string, baseDir = process.cwd()): string {
|
|
42
42
|
const expanded = expandHomeDir(input);
|
|
43
|
-
|
|
43
|
+
const isAbsolute = path.isAbsolute(expanded) || path.win32.isAbsolute(expanded);
|
|
44
|
+
return isAbsolute ? path.normalize(expanded) : path.resolve(baseDir, expanded);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export function getProjectMemoryFilePath(stateDir: string): string {
|
package/src/worker/prompts.ts
CHANGED
|
@@ -193,7 +193,7 @@ export function buildPlanningPrependContext(params: {
|
|
|
193
193
|
"7. Before updating each artifact, post a short chat update naming the artifact you are about to refresh.",
|
|
194
194
|
"8. After updating each artifact, post a short chat update describing what changed and what artifact comes next.",
|
|
195
195
|
"9. Stop after planning artifacts are refreshed and apply-ready. Do not implement code in this turn.",
|
|
196
|
-
"10. End with a concise summary
|
|
196
|
+
"10. End with a concise summary and a mandatory final line exactly in this shape: `Next: run `cs-work` to start implementation.`",
|
|
197
197
|
"11. Never scan sibling directories under `openspec/changes`, never switch to another change, and never restore or rewrite unrelated files.",
|
|
198
198
|
]
|
|
199
199
|
: [
|
|
@@ -134,3 +134,35 @@ test("assistant discussion replies are not journaled after the chat is detached"
|
|
|
134
134
|
|
|
135
135
|
assert.equal(entries.length, 0);
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
test("inbound bot/system messages are ignored by planning journal capture", async () => {
|
|
139
|
+
const harness = await createServiceHarness("clawspec-ignore-bot-inbound-");
|
|
140
|
+
const { service, stateStore, repoPath } = harness;
|
|
141
|
+
const channelKey = "discord:ignore-bot-inbound:default:main";
|
|
142
|
+
const promptContext = createPromptContext("ignore-bot-inbound");
|
|
143
|
+
|
|
144
|
+
await service.startProject(channelKey);
|
|
145
|
+
await service.useProject(channelKey, "demo-app");
|
|
146
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
147
|
+
|
|
148
|
+
await service.recordPlanningMessageFromContext(
|
|
149
|
+
{
|
|
150
|
+
...promptContext,
|
|
151
|
+
from: "assistant",
|
|
152
|
+
metadata: {
|
|
153
|
+
role: "assistant",
|
|
154
|
+
fromSelf: true,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
"Planning ready. Next: run `cs-work` to start implementation.",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
161
|
+
const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
|
|
162
|
+
const entries = await journalStore.list("demo-change");
|
|
163
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
164
|
+
|
|
165
|
+
assert.equal(entries.length, 0);
|
|
166
|
+
assert.equal(project?.planningJournal?.dirty, false);
|
|
167
|
+
assert.equal(project?.planningJournal?.entryCount, 0);
|
|
168
|
+
});
|
package/test/keywords.test.ts
CHANGED
|
@@ -51,6 +51,12 @@ test("parseClawSpecKeyword trims input", () => {
|
|
|
51
51
|
assert.equal(result?.kind, "work");
|
|
52
52
|
});
|
|
53
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
|
+
|
|
54
60
|
test("isClawSpecKeywordText returns boolean correctly", () => {
|
|
55
61
|
assert.equal(isClawSpecKeywordText("cs-plan"), true);
|
|
56
62
|
assert.equal(isClawSpecKeywordText("hello"), false);
|
|
@@ -69,6 +75,12 @@ test("extractEmbeddedClawSpecKeyword finds keyword with args in multiline text",
|
|
|
69
75
|
assert.equal(result?.args, "hello world");
|
|
70
76
|
});
|
|
71
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
|
+
|
|
72
84
|
test("extractEmbeddedClawSpecKeyword returns direct match for single-line input", () => {
|
|
73
85
|
const result = extractEmbeddedClawSpecKeyword("cs-status");
|
|
74
86
|
assert.equal(result?.kind, "status");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { buildOpenSpecInstallMessage, ensureOpenSpecCli } from "../src/dependencies/openspec.ts";
|
|
5
5
|
|
|
6
6
|
const ROOT_PREFIX = process.platform === "win32" ? "C:\\clawspec-test" : "/tmp/clawspec-test";
|
|
7
7
|
|
|
@@ -33,35 +33,36 @@ test("ensureOpenSpecCli uses the global openspec command when available", async
|
|
|
33
33
|
assert.equal(calls.some((call) => call.command === "npm"), false);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test("ensureOpenSpecCli installs
|
|
36
|
+
test("ensureOpenSpecCli installs plugin-local openspec when local and global are unavailable", async () => {
|
|
37
37
|
const calls: Array<{ command: string; args: string[] }> = [];
|
|
38
|
-
let localCheckCount = 0;
|
|
39
|
-
|
|
40
38
|
const result = await ensureOpenSpecCli({
|
|
41
39
|
pluginRoot: ROOT_PREFIX,
|
|
42
40
|
runner: async ({ command, args }) => {
|
|
43
41
|
calls.push({ command, args });
|
|
44
42
|
if (command === LOCAL_COMMAND) {
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
return { code:
|
|
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: "" };
|
|
48
46
|
}
|
|
49
|
-
return { code:
|
|
47
|
+
return { code: 1, stdout: "", stderr: "not found" };
|
|
50
48
|
}
|
|
51
49
|
if (command === "openspec") {
|
|
52
50
|
return { code: 1, stdout: "", stderr: "not found" };
|
|
53
51
|
}
|
|
54
52
|
if (command === "npm") {
|
|
55
|
-
return { code: 0, stdout: "installed
|
|
53
|
+
return { code: 0, stdout: "installed", stderr: "" };
|
|
56
54
|
}
|
|
57
55
|
return { code: 1, stdout: "", stderr: "unexpected command" };
|
|
58
56
|
},
|
|
59
57
|
});
|
|
60
58
|
|
|
61
59
|
assert.equal(result.source, "local");
|
|
62
|
-
assert.equal(result.version, "1.2.
|
|
63
|
-
assert.equal(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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/);
|
|
67
68
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
|
|
@@ -156,6 +156,7 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
|
|
|
156
156
|
const runningProject = await stateStore.getActiveProject(channelKey);
|
|
157
157
|
|
|
158
158
|
assert.match(injected?.prependContext ?? "", /ClawSpec planning sync is active for this turn/);
|
|
159
|
+
assert.match(injected?.prependContext ?? "", /mandatory final line exactly in this shape/i);
|
|
159
160
|
assert.equal(runningProject?.status, "planning");
|
|
160
161
|
assert.equal(runningProject?.phase, "planning_sync");
|
|
161
162
|
|
|
@@ -172,6 +173,7 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
|
|
|
172
173
|
assert.equal(finalized?.status, "ready");
|
|
173
174
|
assert.equal(finalized?.phase, "tasks");
|
|
174
175
|
assert.equal(finalized?.planningJournal?.dirty, false);
|
|
176
|
+
assert.match(finalized?.latestSummary ?? "", /Say `cs-work` to start implementation/);
|
|
175
177
|
assert.equal(snapshotExists, true);
|
|
176
178
|
assert.equal(snapshot?.changeName, "demo-change");
|
|
177
179
|
});
|
package/test/use-project.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { pathExists } from "../src/utils/fs.ts";
|
|
5
6
|
import { createServiceHarness } from "./helpers/harness.ts";
|
|
@@ -17,3 +18,50 @@ test("useProject initializes repo and selects project", async () => {
|
|
|
17
18
|
assert.equal(project?.repoPath, path.join(workspacePath, "demo-app"));
|
|
18
19
|
assert.equal(await pathExists(path.join(workspacePath, "demo-app", "openspec", "config.yaml")), true);
|
|
19
20
|
});
|
|
21
|
+
|
|
22
|
+
test("workspaceProject resolves quoted home-relative path without nesting into default workspace", async () => {
|
|
23
|
+
const harness = await createServiceHarness("clawspec-workspace-home-");
|
|
24
|
+
const { service, stateStore, workspacePath } = harness;
|
|
25
|
+
const channelKey = "discord:workspace-home:default:main";
|
|
26
|
+
|
|
27
|
+
await service.startProject(channelKey);
|
|
28
|
+
const result = await service.workspaceProject(channelKey, "\"~/Desktop/workspace/ai_workspacce\"");
|
|
29
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
30
|
+
const expected = path.join(os.homedir(), "Desktop", "workspace", "ai_workspacce");
|
|
31
|
+
|
|
32
|
+
assert.match(result.text ?? "", /Workspace switched/);
|
|
33
|
+
assert.equal(project?.workspacePath, expected);
|
|
34
|
+
assert.equal(project?.workspacePath?.includes(workspacePath), false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("workspaceProject keeps absolute paths as absolute targets", async () => {
|
|
38
|
+
const harness = await createServiceHarness("clawspec-workspace-abs-");
|
|
39
|
+
const { service, stateStore } = harness;
|
|
40
|
+
const channelKey = "discord:workspace-abs:default:main";
|
|
41
|
+
|
|
42
|
+
await service.startProject(channelKey);
|
|
43
|
+
|
|
44
|
+
const unixAbsolute = process.platform === "win32" ? "/var/tmp/clawspec-abs" : "/tmp/clawspec-abs";
|
|
45
|
+
await service.workspaceProject(channelKey, unixAbsolute);
|
|
46
|
+
const projectAfterUnix = await stateStore.getActiveProject(channelKey);
|
|
47
|
+
assert.equal(projectAfterUnix?.workspacePath, path.normalize(unixAbsolute));
|
|
48
|
+
|
|
49
|
+
const driveAbsolute = "C:\\Users\\dev\\workspace\\clawspec-abs";
|
|
50
|
+
await service.workspaceProject(channelKey, driveAbsolute);
|
|
51
|
+
const projectAfterDrive = await stateStore.getActiveProject(channelKey);
|
|
52
|
+
assert.equal(projectAfterDrive?.workspacePath, path.normalize(driveAbsolute));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("useProject accepts quoted project names with spaces", async () => {
|
|
56
|
+
const harness = await createServiceHarness("clawspec-use-project-space-");
|
|
57
|
+
const { service, stateStore, workspacePath } = harness;
|
|
58
|
+
const channelKey = "discord:use-project-space:default:main";
|
|
59
|
+
|
|
60
|
+
await service.startProject(channelKey);
|
|
61
|
+
const result = await service.useProject(channelKey, "\"team app\"");
|
|
62
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
63
|
+
|
|
64
|
+
assert.match(result.text ?? "", /Project Selected/);
|
|
65
|
+
assert.equal(project?.projectName, "team app");
|
|
66
|
+
assert.equal(project?.repoPath, path.join(workspacePath, "team app"));
|
|
67
|
+
});
|