clawspec 1.0.3 → 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 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
- Recommended public install after publishing to npm:
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
- `@latest` always resolves to the newest published ClawSpec release on npm.
235
+ Option B: ClawHub CLI installer
236
+
237
+ ```powershell
238
+ npx clawhub@latest install clawspec
239
+ ```
234
240
 
235
- Current OpenClaw builds do not accept raw GitHub URLs as ordinary plugin install specs. A GitHub repo by itself is not enough for `openclaw plugins install`; the standard public path is an npm package spec such as `clawspec@latest`.
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
- npm 发布后的标准安装方式:
232
+ 常见安装方式有三种:
233
+
234
+ 方式 A:通过 OpenClaw 插件安装器(推荐)
233
235
 
234
236
  ```powershell
235
237
  openclaw plugins install clawspec@latest
236
238
  ```
237
239
 
238
- `@latest` 会始终解析到 npm 上最新发布的 ClawSpec 版本。
240
+ 方式 B:通过 ClawHub CLI 安装
241
+
242
+ ```powershell
243
+ npx clawhub@latest install clawspec
244
+ ```
239
245
 
240
- 当前 OpenClaw 一般不接受把 GitHub 仓库 URL 直接当作普通插件安装源。也就是说,单独一个 GitHub repo 还不够,公开安装路径通常还是 npm 包规格,例如 `clawspec@latest`。
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",
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": [
@@ -32,7 +32,10 @@
32
32
  "openclaw": {
33
33
  "extensions": [
34
34
  "./index.ts"
35
- ]
35
+ ],
36
+ "compat": {
37
+ "pluginApi": ">=2026.3.24"
38
+ }
36
39
  },
37
40
  "peerDependencies": {
38
41
  "openclaw": ">=0.0.0"
@@ -43,7 +46,7 @@
43
46
  "scripts": {
44
47
  "check": "node --experimental-strip-types -e \"import('./index.ts')\"",
45
48
  "test": "node --experimental-strip-types --test --test-reporter spec test/*.test.ts",
46
- "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",
47
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"
48
51
  },
49
52
  "engines": {
@@ -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.toLowerCase().startsWith("cs-")) {
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 kind = COMMAND_ALIASES[rawCommand.toLowerCase()];
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: rawCommand.toLowerCase(),
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 embeddedMatch = text.match(EMBEDDED_KEYWORD_PATTERN);
67
- if (!embeddedMatch?.[1]) {
68
- return null;
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 parseClawSpecKeyword(embeddedMatch[1]);
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 found (${globalCheck.message}); installing plugin-local ${OPENSPEC_PACKAGE_NAME}`,
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: ["install", "--omit=dev", "--no-save", "--package-lock=false", OPENSPEC_PACKAGE_NAME],
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 required to install plugin-local openspec but was not found on PATH");
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 { buildChannelKeyFromCommand, buildLegacyChannelKeyFromCommand } from "../utils/channel-key.ts";
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 requested = rawArgs.trim();
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 input = rawArgs.trim();
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
  }
@@ -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
- return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(baseDir, expanded);
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 {
@@ -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 of what changed, what remains open, and tell the user to say `cs-work` when they want implementation to start.",
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
+ });
@@ -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 { ensureOpenSpecCli, OPENSPEC_PACKAGE_NAME } from "../src/dependencies/openspec.ts";
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 a plugin-local openspec when none is available", async () => {
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
- localCheckCount += 1;
46
- if (localCheckCount === 1) {
47
- return { code: 1, stdout: "", stderr: "not found" };
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: 0, stdout: "1.2.0\n", stderr: "" };
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\n", stderr: "" };
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.0");
63
- assert.equal(
64
- calls.some((call) => call.command === "npm" && call.args.includes(OPENSPEC_PACKAGE_NAME)),
65
- true,
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
  });
@@ -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
+ });