clawspec 1.0.4 → 1.0.6
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 +220 -15
- package/src/utils/paths.ts +4 -1
- package/src/worker/prompts.ts +19 -8
- package/test/assistant-journal.test.ts +32 -0
- package/test/helpers/harness.ts +21 -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 +4 -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.6",
|
|
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
|
+
}
|
|
@@ -23,12 +23,17 @@ import type {
|
|
|
23
23
|
ExecutionResult,
|
|
24
24
|
OpenSpecApplyInstructionsResponse,
|
|
25
25
|
OpenSpecCommandResult,
|
|
26
|
+
OpenSpecInstructionsResponse,
|
|
26
27
|
OpenSpecStatusResponse,
|
|
27
28
|
ProjectState,
|
|
28
29
|
TaskCountSummary,
|
|
29
30
|
} from "../types.ts";
|
|
30
31
|
import { splitSubcommand, tokenizeArgs } from "../utils/args.ts";
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
buildChannelKeyFromCommand,
|
|
34
|
+
buildLegacyChannelKeyFromCommand,
|
|
35
|
+
parseChannelKey,
|
|
36
|
+
} from "../utils/channel-key.ts";
|
|
32
37
|
import {
|
|
33
38
|
appendUtf8,
|
|
34
39
|
directoryExists,
|
|
@@ -562,6 +567,7 @@ export class ClawSpecService {
|
|
|
562
567
|
private async buildPlanningSyncInjection(
|
|
563
568
|
project: ProjectState,
|
|
564
569
|
userPrompt: string,
|
|
570
|
+
instructionResults: Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>>,
|
|
565
571
|
): Promise<{ prependContext?: string; prependSystemContext?: string }> {
|
|
566
572
|
const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
|
|
567
573
|
await this.ensureProjectSupportFiles(project);
|
|
@@ -580,13 +586,19 @@ export class ClawSpecService {
|
|
|
580
586
|
contextPaths: planningContext.paths,
|
|
581
587
|
scaffoldOnly: planningContext.scaffoldOnly,
|
|
582
588
|
mode: "sync",
|
|
589
|
+
prefetchedInstructions: instructionResults.map((result) => result.parsed!).filter(Boolean),
|
|
583
590
|
}),
|
|
584
591
|
};
|
|
585
592
|
}
|
|
586
593
|
|
|
587
594
|
private async preparePlanningSync(channelKey: string): Promise<
|
|
588
595
|
| { result: PluginCommandResult }
|
|
589
|
-
| {
|
|
596
|
+
| {
|
|
597
|
+
project: ProjectState;
|
|
598
|
+
outputs: OpenSpecCommandResult[];
|
|
599
|
+
repoStatePaths: RepoStatePaths;
|
|
600
|
+
instructionResults: Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>>;
|
|
601
|
+
}
|
|
590
602
|
> {
|
|
591
603
|
const project = await this.requireActiveProject(channelKey);
|
|
592
604
|
if (!project.repoPath || !project.projectName || !project.changeName) {
|
|
@@ -657,6 +669,21 @@ export class ClawSpecService {
|
|
|
657
669
|
}
|
|
658
670
|
const statusResult = await this.openSpec.status(project.repoPath, project.changeName);
|
|
659
671
|
outputs.push(statusResult);
|
|
672
|
+
const instructionResults = await this.refreshPlanningInstructionFiles(project, repoStatePaths);
|
|
673
|
+
outputs.push(...instructionResults);
|
|
674
|
+
await this.writeLatestSummary(
|
|
675
|
+
repoStatePaths,
|
|
676
|
+
`Planning instructions refreshed for ${project.changeName} via OpenSpec CLI.`,
|
|
677
|
+
);
|
|
678
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
679
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
680
|
+
await removeIfExists(repoStatePaths.workerProgressFile);
|
|
681
|
+
return {
|
|
682
|
+
project,
|
|
683
|
+
outputs,
|
|
684
|
+
repoStatePaths,
|
|
685
|
+
instructionResults,
|
|
686
|
+
};
|
|
660
687
|
} catch (error) {
|
|
661
688
|
if (error instanceof OpenSpecCommandError) {
|
|
662
689
|
return {
|
|
@@ -673,15 +700,6 @@ export class ClawSpecService {
|
|
|
673
700
|
}
|
|
674
701
|
throw error;
|
|
675
702
|
}
|
|
676
|
-
|
|
677
|
-
await removeIfExists(repoStatePaths.executionControlFile);
|
|
678
|
-
await removeIfExists(repoStatePaths.executionResultFile);
|
|
679
|
-
await removeIfExists(repoStatePaths.workerProgressFile);
|
|
680
|
-
return {
|
|
681
|
-
project,
|
|
682
|
-
outputs,
|
|
683
|
-
repoStatePaths,
|
|
684
|
-
};
|
|
685
703
|
}
|
|
686
704
|
|
|
687
705
|
private async startVisiblePlanningSync(
|
|
@@ -713,7 +731,34 @@ export class ClawSpecService {
|
|
|
713
731
|
lastExecutionAt: startedAt,
|
|
714
732
|
}));
|
|
715
733
|
|
|
716
|
-
return await this.buildPlanningSyncInjection(runningProject, userPrompt);
|
|
734
|
+
return await this.buildPlanningSyncInjection(runningProject, userPrompt, prepared.instructionResults);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private async refreshPlanningInstructionFiles(
|
|
738
|
+
project: ProjectState,
|
|
739
|
+
repoStatePaths: RepoStatePaths,
|
|
740
|
+
): Promise<Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>>> {
|
|
741
|
+
if (!project.repoPath || !project.changeName) {
|
|
742
|
+
return [];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const artifactIds = ["proposal", "specs", "design", "tasks"] as const;
|
|
746
|
+
await ensureDir(repoStatePaths.planningInstructionsRoot);
|
|
747
|
+
const results: Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>> = [];
|
|
748
|
+
|
|
749
|
+
for (const artifactId of artifactIds) {
|
|
750
|
+
const result = await this.openSpec.instructionsArtifact(project.repoPath, artifactId, project.changeName);
|
|
751
|
+
results.push(result);
|
|
752
|
+
await writeJsonFile(path.join(repoStatePaths.planningInstructionsRoot, `${artifactId}.json`), {
|
|
753
|
+
generatedAt: new Date().toISOString(),
|
|
754
|
+
command: result.command,
|
|
755
|
+
cwd: result.cwd,
|
|
756
|
+
durationMs: result.durationMs,
|
|
757
|
+
instruction: result.parsed,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return results;
|
|
717
762
|
}
|
|
718
763
|
|
|
719
764
|
private async collectPlanningContextPaths(
|
|
@@ -724,6 +769,17 @@ export class ClawSpecService {
|
|
|
724
769
|
repoStatePaths.stateFile,
|
|
725
770
|
repoStatePaths.planningJournalFile,
|
|
726
771
|
];
|
|
772
|
+
const instructionFiles = [
|
|
773
|
+
path.join(repoStatePaths.planningInstructionsRoot, "proposal.json"),
|
|
774
|
+
path.join(repoStatePaths.planningInstructionsRoot, "specs.json"),
|
|
775
|
+
path.join(repoStatePaths.planningInstructionsRoot, "design.json"),
|
|
776
|
+
path.join(repoStatePaths.planningInstructionsRoot, "tasks.json"),
|
|
777
|
+
];
|
|
778
|
+
for (const instructionFile of instructionFiles) {
|
|
779
|
+
if (await pathExists(instructionFile)) {
|
|
780
|
+
paths.push(instructionFile);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
727
783
|
|
|
728
784
|
if (!project.changeDir) {
|
|
729
785
|
return {
|
|
@@ -955,7 +1011,11 @@ export class ClawSpecService {
|
|
|
955
1011
|
async workspaceProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
|
|
956
1012
|
const currentWorkspace = await this.workspaceStore.getCurrentWorkspace(channelKey);
|
|
957
1013
|
const project = await this.ensureSessionProject(channelKey, currentWorkspace);
|
|
958
|
-
const
|
|
1014
|
+
const parsedArg = this.parseSinglePathArgument(rawArgs, "Usage: `/clawspec workspace <path>`.\nIf the path contains spaces, wrap it in quotes.");
|
|
1015
|
+
if ("error" in parsedArg) {
|
|
1016
|
+
return errorReply(parsedArg.error);
|
|
1017
|
+
}
|
|
1018
|
+
const requested = parsedArg.value;
|
|
959
1019
|
|
|
960
1020
|
if (!requested) {
|
|
961
1021
|
return okReply(await this.buildWorkspaceText(project));
|
|
@@ -1003,7 +1063,11 @@ export class ClawSpecService {
|
|
|
1003
1063
|
async useProject(channelKey: string, rawArgs: string): Promise<PluginCommandResult> {
|
|
1004
1064
|
const workspacePath = await this.workspaceStore.getCurrentWorkspace(channelKey);
|
|
1005
1065
|
const project = await this.ensureSessionProject(channelKey, workspacePath);
|
|
1006
|
-
const
|
|
1066
|
+
const parsedArg = this.parseSinglePathArgument(rawArgs, "Usage: `/clawspec use <project-name>`.\nIf the project path contains spaces, wrap it in quotes.");
|
|
1067
|
+
if ("error" in parsedArg) {
|
|
1068
|
+
return errorReply(parsedArg.error);
|
|
1069
|
+
}
|
|
1070
|
+
const input = parsedArg.value;
|
|
1007
1071
|
|
|
1008
1072
|
if (!input) {
|
|
1009
1073
|
return okReply(await this.buildWorkspaceText(project));
|
|
@@ -1854,6 +1918,7 @@ export class ClawSpecService {
|
|
|
1854
1918
|
channelId?: string;
|
|
1855
1919
|
accountId?: string;
|
|
1856
1920
|
conversationId?: string;
|
|
1921
|
+
from?: string;
|
|
1857
1922
|
metadata?: Record<string, unknown>;
|
|
1858
1923
|
}, text: string): boolean {
|
|
1859
1924
|
const normalized = sanitizePlanningMessageText(text).trim();
|
|
@@ -1862,6 +1927,10 @@ export class ClawSpecService {
|
|
|
1862
1927
|
}
|
|
1863
1928
|
|
|
1864
1929
|
const metadata = params.metadata;
|
|
1930
|
+
const normalizedFrom = params.from?.trim().toLowerCase();
|
|
1931
|
+
if (normalizedFrom && ["assistant", "bot", "system", "plugin", "agent", "tool", "worker"].includes(normalizedFrom)) {
|
|
1932
|
+
return true;
|
|
1933
|
+
}
|
|
1865
1934
|
const selfFlags = [
|
|
1866
1935
|
metadata?.fromSelf,
|
|
1867
1936
|
metadata?.isSelf,
|
|
@@ -1874,6 +1943,20 @@ export class ClawSpecService {
|
|
|
1874
1943
|
return true;
|
|
1875
1944
|
}
|
|
1876
1945
|
|
|
1946
|
+
const metadataRoles = [
|
|
1947
|
+
metadata?.role,
|
|
1948
|
+
metadata?.senderRole,
|
|
1949
|
+
metadata?.authorRole,
|
|
1950
|
+
metadata?.messageRole,
|
|
1951
|
+
metadata?.sourceRole,
|
|
1952
|
+
metadata?.actorRole,
|
|
1953
|
+
]
|
|
1954
|
+
.filter((value): value is string => typeof value === "string")
|
|
1955
|
+
.map((value) => value.trim().toLowerCase());
|
|
1956
|
+
if (metadataRoles.some((value) => ["assistant", "bot", "system", "plugin", "agent", "tool", "worker"].includes(value))) {
|
|
1957
|
+
return true;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1877
1960
|
const scopeKey = this.buildMessageScopeKey(params);
|
|
1878
1961
|
const now = Date.now();
|
|
1879
1962
|
const entries = (this.recentOutboundMessages.get(scopeKey) ?? [])
|
|
@@ -1897,6 +1980,118 @@ export class ClawSpecService {
|
|
|
1897
1980
|
].join(":");
|
|
1898
1981
|
}
|
|
1899
1982
|
|
|
1983
|
+
private parseSinglePathArgument(
|
|
1984
|
+
rawArgs: string,
|
|
1985
|
+
usageMessage: string,
|
|
1986
|
+
): { value: string } | { error: string } {
|
|
1987
|
+
const trimmed = rawArgs.trim();
|
|
1988
|
+
if (!trimmed) {
|
|
1989
|
+
return { value: "" };
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
const firstChar = trimmed[0];
|
|
1993
|
+
if (firstChar === "\"" || firstChar === "'") {
|
|
1994
|
+
if (trimmed.length < 2 || !trimmed.endsWith(firstChar)) {
|
|
1995
|
+
return { error: "Unterminated quoted argument." };
|
|
1996
|
+
}
|
|
1997
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
1998
|
+
return { value: inner };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
if (/\s/.test(trimmed)) {
|
|
2002
|
+
return { error: usageMessage };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
return { value: trimmed };
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
private async maybeSendPlanningNextStepNotice(project: ProjectState, event: AgentEndEvent): Promise<void> {
|
|
2009
|
+
if (!project.changeName) {
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const latestAssistantText = sanitizePlanningMessageText(extractLatestMessageTextByRole(event.messages, "assistant") ?? "").toLowerCase();
|
|
2014
|
+
const alreadyContainsNextStep = latestAssistantText.includes("cs-work")
|
|
2015
|
+
|| latestAssistantText.includes("/clawspec archive")
|
|
2016
|
+
|| latestAssistantText.includes("run `cs-plan`");
|
|
2017
|
+
if (alreadyContainsNextStep) {
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
const projectLabel = project.projectName ?? "project";
|
|
2022
|
+
if (project.status === "ready" && project.phase === "tasks") {
|
|
2023
|
+
await this.sendChannelUpdate(
|
|
2024
|
+
project.channelKey,
|
|
2025
|
+
`✅ ${projectLabel}-${project.changeName} Planning ready. Next: run \`cs-work\` to start implementation.`,
|
|
2026
|
+
);
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (project.status === "done") {
|
|
2031
|
+
await this.sendChannelUpdate(
|
|
2032
|
+
project.channelKey,
|
|
2033
|
+
`🏁 ${projectLabel}-${project.changeName} Planning complete and tasks are already done. Next: use \`/clawspec archive\`.`,
|
|
2034
|
+
);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
if (project.status === "blocked") {
|
|
2039
|
+
await this.sendChannelUpdate(
|
|
2040
|
+
project.channelKey,
|
|
2041
|
+
`⚠ ${projectLabel}-${project.changeName} Planning blocked. Next: review blockers, then run \`cs-plan\` again.`,
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
private async sendChannelUpdate(channelKey: string, text: string): Promise<void> {
|
|
2047
|
+
const runtime = (this.api as { runtime?: OpenClawPluginApi["runtime"] }).runtime;
|
|
2048
|
+
if (!runtime?.channel) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
const route = parseChannelKey(channelKey);
|
|
2053
|
+
const accountId = route.accountId && route.accountId !== "default" ? route.accountId : undefined;
|
|
2054
|
+
|
|
2055
|
+
try {
|
|
2056
|
+
switch (route.channel) {
|
|
2057
|
+
case "discord":
|
|
2058
|
+
await runtime.channel.discord.sendMessageDiscord(`channel:${route.channelId}`, text, {
|
|
2059
|
+
cfg: this.api.config,
|
|
2060
|
+
accountId,
|
|
2061
|
+
silent: true,
|
|
2062
|
+
});
|
|
2063
|
+
return;
|
|
2064
|
+
case "telegram":
|
|
2065
|
+
await runtime.channel.telegram.sendMessageTelegram(route.channelId, text, {
|
|
2066
|
+
cfg: this.api.config,
|
|
2067
|
+
accountId,
|
|
2068
|
+
silent: true,
|
|
2069
|
+
messageThreadId: parseOptionalNumber(route.conversationId),
|
|
2070
|
+
});
|
|
2071
|
+
return;
|
|
2072
|
+
case "slack":
|
|
2073
|
+
await runtime.channel.slack.sendMessageSlack(route.channelId, text, {
|
|
2074
|
+
cfg: this.api.config,
|
|
2075
|
+
accountId,
|
|
2076
|
+
threadTs: route.conversationId !== "main" ? route.conversationId : undefined,
|
|
2077
|
+
});
|
|
2078
|
+
return;
|
|
2079
|
+
case "signal":
|
|
2080
|
+
await runtime.channel.signal.sendMessageSignal(route.channelId, text, {
|
|
2081
|
+
cfg: this.api.config,
|
|
2082
|
+
accountId,
|
|
2083
|
+
});
|
|
2084
|
+
return;
|
|
2085
|
+
default:
|
|
2086
|
+
this.logger.info(`[clawspec] planning notice (${route.channel} ${route.channelId}): ${text}`);
|
|
2087
|
+
}
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
this.logger.warn(
|
|
2090
|
+
`[clawspec] failed to send planning notice to ${channelKey}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1900
2095
|
private async ensureSessionProject(channelKey: string, workspacePath: string): Promise<ProjectState> {
|
|
1901
2096
|
const existing = await this.stateStore.getActiveProject(channelKey);
|
|
1902
2097
|
if (existing) {
|
|
@@ -2200,7 +2395,7 @@ export class ClawSpecService {
|
|
|
2200
2395
|
}
|
|
2201
2396
|
await this.writeLatestSummary(repoStatePaths, latestSummary);
|
|
2202
2397
|
|
|
2203
|
-
await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
2398
|
+
const finalized = await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
2204
2399
|
...current,
|
|
2205
2400
|
status,
|
|
2206
2401
|
phase,
|
|
@@ -2219,6 +2414,8 @@ export class ClawSpecService {
|
|
|
2219
2414
|
lastSyncedAt,
|
|
2220
2415
|
},
|
|
2221
2416
|
}));
|
|
2417
|
+
|
|
2418
|
+
await this.maybeSendPlanningNextStepNotice(finalized, event);
|
|
2222
2419
|
}
|
|
2223
2420
|
|
|
2224
2421
|
private resolvePostRunStatus(
|
|
@@ -3008,6 +3205,14 @@ function isWorkflowControlLine(line: string): boolean {
|
|
|
3008
3205
|
|| lower === "`cs-work` is not available yet.";
|
|
3009
3206
|
}
|
|
3010
3207
|
|
|
3208
|
+
function parseOptionalNumber(value: string): number | undefined {
|
|
3209
|
+
if (!value || value === "main") {
|
|
3210
|
+
return undefined;
|
|
3211
|
+
}
|
|
3212
|
+
const parsed = Number(value);
|
|
3213
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3011
3216
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
3012
3217
|
return typeof value === "object" && value !== null;
|
|
3013
3218
|
}
|
package/src/utils/paths.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type RepoStatePaths = {
|
|
|
14
14
|
latestSummaryFile: string;
|
|
15
15
|
planningJournalFile: string;
|
|
16
16
|
planningJournalSnapshotFile: string;
|
|
17
|
+
planningInstructionsRoot: string;
|
|
17
18
|
rollbackManifestFile: string;
|
|
18
19
|
snapshotsRoot: string;
|
|
19
20
|
archivesRoot: string;
|
|
@@ -40,7 +41,8 @@ export function expandHomeDir(input: string): string {
|
|
|
40
41
|
|
|
41
42
|
export function resolveUserPath(input: string, baseDir = process.cwd()): string {
|
|
42
43
|
const expanded = expandHomeDir(input);
|
|
43
|
-
|
|
44
|
+
const isAbsolute = path.isAbsolute(expanded) || path.win32.isAbsolute(expanded);
|
|
45
|
+
return isAbsolute ? path.normalize(expanded) : path.resolve(baseDir, expanded);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export function getProjectMemoryFilePath(stateDir: string): string {
|
|
@@ -74,6 +76,7 @@ export function getRepoStatePaths(repoPath: string, archiveDirName: string): Rep
|
|
|
74
76
|
latestSummaryFile: path.join(root, "latest-summary.md"),
|
|
75
77
|
planningJournalFile: path.join(root, "planning-journal.jsonl"),
|
|
76
78
|
planningJournalSnapshotFile: path.join(root, "planning-journal.snapshot.json"),
|
|
79
|
+
planningInstructionsRoot: path.join(root, "planning-instructions"),
|
|
77
80
|
rollbackManifestFile: path.join(root, "rollback-manifest.json"),
|
|
78
81
|
snapshotsRoot: path.join(root, "snapshots"),
|
|
79
82
|
archivesRoot: path.join(root, archiveDirName),
|
package/src/worker/prompts.ts
CHANGED
|
@@ -139,6 +139,7 @@ export function buildExecutionPrependContext(params: {
|
|
|
139
139
|
"4. If planning-journal state is dirty or planning artifacts are missing, sync `proposal`, `specs`, `design`, and `tasks` in order using `openspec instructions <artifact> --change <name> --json`.",
|
|
140
140
|
"5. After planning sync, run `openspec instructions apply --change <name> --json`, read the returned context files, and use that instruction as the implementation guide.",
|
|
141
141
|
"6. Execute unchecked tasks from tasks.md sequentially. Each time a task is fully complete, update its checkbox from `- [ ]` to `- [x]` immediately.",
|
|
142
|
+
"6.1 For code-change tasks, add or update automated tests before marking the task done, and report the test command/result in progress updates.",
|
|
142
143
|
"7. Between artifacts and tasks, re-check execution-control.json for pauseRequested or cancelRequested.",
|
|
143
144
|
"8. Keep OpenSpec command activity visible by running those commands normally in this chat.",
|
|
144
145
|
"9. Keep the user informed in this chat with explicit progress messages.",
|
|
@@ -177,6 +178,7 @@ export function buildPlanningPrependContext(params: {
|
|
|
177
178
|
scaffoldOnly?: boolean;
|
|
178
179
|
mode: "discussion" | "sync";
|
|
179
180
|
nextActionHint?: "plan" | "work";
|
|
181
|
+
prefetchedInstructions?: OpenSpecInstructionsResponse[];
|
|
180
182
|
}): string {
|
|
181
183
|
const project = params.project;
|
|
182
184
|
|
|
@@ -185,16 +187,17 @@ export function buildPlanningPrependContext(params: {
|
|
|
185
187
|
"Required workflow for this turn:",
|
|
186
188
|
"0. The active change directory shown above is the only OpenSpec change directory you may inspect or modify in this turn.",
|
|
187
189
|
"1. Read planning-journal.jsonl, .openspec.yaml, and any planning artifacts that already exist.",
|
|
188
|
-
"2.
|
|
189
|
-
"3.
|
|
190
|
-
"4.
|
|
191
|
-
"5. If artifacts are missing or stale,
|
|
192
|
-
"6.
|
|
190
|
+
"2. Treat the prefetched OpenSpec instruction files in this context as the authoritative source for artifact structure, output paths, and writing guidance.",
|
|
191
|
+
"3. Use the current visible chat context plus the planning journal to decide whether there are substantive new requirements, constraints, or design changes since the last planning sync.",
|
|
192
|
+
"4. If there is no substantive planning change, say so clearly in chat and do not rewrite artifacts unnecessarily.",
|
|
193
|
+
"5. If artifacts are missing or stale, update `proposal`, `specs`, `design`, and `tasks` in dependency order using those prefetched OpenSpec instruction files.",
|
|
194
|
+
"6. Do not generate or rewrite planning artifacts from ad-hoc structure guesses; follow OpenSpec instruction/template constraints only.",
|
|
193
195
|
"7. Before updating each artifact, post a short chat update naming the artifact you are about to refresh.",
|
|
194
196
|
"8. After updating each artifact, post a short chat update describing what changed and what artifact comes next.",
|
|
195
|
-
"9.
|
|
196
|
-
"10.
|
|
197
|
-
"11.
|
|
197
|
+
"9. For any implementation-oriented task item, ensure tasks.md contains an explicit testing task (new tests or updated tests) and a validation command.",
|
|
198
|
+
"10. Stop after planning artifacts are refreshed and apply-ready. Do not implement code in this turn.",
|
|
199
|
+
"11. End with a concise summary and a mandatory final line exactly in this shape: `Next: run `cs-work` to start implementation.`",
|
|
200
|
+
"12. Never scan sibling directories under `openspec/changes`, never switch to another change, and never restore or rewrite unrelated files.",
|
|
198
201
|
]
|
|
199
202
|
: [
|
|
200
203
|
"Discussion rules for this turn:",
|
|
@@ -232,6 +235,14 @@ export function buildPlanningPrependContext(params: {
|
|
|
232
235
|
"",
|
|
233
236
|
"Read these files before responding:",
|
|
234
237
|
...params.contextPaths.map((contextPath) => `- ${contextPath}`),
|
|
238
|
+
params.mode === "sync" ? "" : "",
|
|
239
|
+
params.mode === "sync" ? "Prefetched OpenSpec instructions for this turn:" : "",
|
|
240
|
+
...(params.mode === "sync"
|
|
241
|
+
? (params.prefetchedInstructions ?? [])
|
|
242
|
+
.map((instruction) =>
|
|
243
|
+
`- ${instruction.artifactId}: ${displayPath(resolveProjectScopedPath(project, instruction.outputPath))}`,
|
|
244
|
+
)
|
|
245
|
+
: []),
|
|
235
246
|
params.scaffoldOnly ? "" : "",
|
|
236
247
|
params.scaffoldOnly ? "Only the change scaffold exists right now. That is expected before planning sync generates the first artifacts." : "",
|
|
237
248
|
"",
|
|
@@ -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/helpers/harness.ts
CHANGED
|
@@ -133,6 +133,27 @@ export async function createServiceHarness(prefix: string): Promise<{
|
|
|
133
133
|
],
|
|
134
134
|
},
|
|
135
135
|
}),
|
|
136
|
+
instructionsArtifact: async (cwd: string, artifactId: string, changeName: string) => ({
|
|
137
|
+
command: `openspec instructions ${artifactId} --change ${changeName} --json`,
|
|
138
|
+
cwd,
|
|
139
|
+
stdout: "{}",
|
|
140
|
+
stderr: "",
|
|
141
|
+
durationMs: 1,
|
|
142
|
+
parsed: {
|
|
143
|
+
changeName,
|
|
144
|
+
artifactId,
|
|
145
|
+
schemaName: "spec-driven",
|
|
146
|
+
changeDir,
|
|
147
|
+
outputPath: artifactId === "specs"
|
|
148
|
+
? path.join(changeDir, "specs", "demo-spec", "spec.md")
|
|
149
|
+
: path.join(changeDir, `${artifactId}.md`),
|
|
150
|
+
description: `Refresh ${artifactId}`,
|
|
151
|
+
instruction: `Use ${artifactId} template`,
|
|
152
|
+
template: `# ${artifactId}`,
|
|
153
|
+
dependencies: [],
|
|
154
|
+
unlocks: [],
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
136
157
|
instructionsApply: async (cwd: string, changeName: string) => ({
|
|
137
158
|
command: `openspec instructions apply --change ${changeName} --json`,
|
|
138
159
|
cwd,
|
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,9 @@ 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 ?? "", /Prefetched OpenSpec instructions for this turn/);
|
|
160
|
+
assert.match(injected?.prependContext ?? "", /planning-instructions[\\/]+proposal\.json/);
|
|
161
|
+
assert.match(injected?.prependContext ?? "", /mandatory final line exactly in this shape/i);
|
|
159
162
|
assert.equal(runningProject?.status, "planning");
|
|
160
163
|
assert.equal(runningProject?.phase, "planning_sync");
|
|
161
164
|
|
|
@@ -172,6 +175,7 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
|
|
|
172
175
|
assert.equal(finalized?.status, "ready");
|
|
173
176
|
assert.equal(finalized?.phase, "tasks");
|
|
174
177
|
assert.equal(finalized?.planningJournal?.dirty, false);
|
|
178
|
+
assert.match(finalized?.latestSummary ?? "", /Say `cs-work` to start implementation/);
|
|
175
179
|
assert.equal(snapshotExists, true);
|
|
176
180
|
assert.equal(snapshot?.changeName, "demo-change");
|
|
177
181
|
});
|
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
|
+
});
|