clawspec 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +6 -0
  2. package/README.zh-CN.md +6 -0
  3. package/package.json +1 -2
  4. package/src/bootstrap/state.ts +128 -0
  5. package/src/dependencies/acpx.ts +6 -0
  6. package/src/dependencies/openspec.ts +5 -0
  7. package/src/index.ts +125 -43
  8. package/src/watchers/manager.ts +69 -1
  9. package/test/acp-client.test.ts +0 -309
  10. package/test/acpx-dependency.test.ts +0 -133
  11. package/test/assistant-journal.test.ts +0 -203
  12. package/test/command-surface.test.ts +0 -24
  13. package/test/config.test.ts +0 -77
  14. package/test/detach-attach.test.ts +0 -98
  15. package/test/doctor.test.ts +0 -142
  16. package/test/file-lock.test.ts +0 -88
  17. package/test/fs-utils.test.ts +0 -22
  18. package/test/helpers/harness.ts +0 -305
  19. package/test/helpers.test.ts +0 -108
  20. package/test/keywords.test.ts +0 -92
  21. package/test/notifier.test.ts +0 -29
  22. package/test/openspec-dependency.test.ts +0 -68
  23. package/test/paths-utils.test.ts +0 -30
  24. package/test/pause-cancel.test.ts +0 -55
  25. package/test/planning-journal.test.ts +0 -155
  26. package/test/plugin-registration.test.ts +0 -35
  27. package/test/project-memory.test.ts +0 -42
  28. package/test/proposal.test.ts +0 -24
  29. package/test/queue-planning.test.ts +0 -322
  30. package/test/queue-work.test.ts +0 -220
  31. package/test/recovery.test.ts +0 -603
  32. package/test/service-archive.test.ts +0 -87
  33. package/test/shell-command.test.ts +0 -48
  34. package/test/state-store.test.ts +0 -74
  35. package/test/tasks-and-checkpoint.test.ts +0 -60
  36. package/test/use-project.test.ts +0 -67
  37. package/test/watcher-planning.test.ts +0 -533
  38. package/test/watcher-work.test.ts +0 -1771
  39. package/test/worker-command.test.ts +0 -66
  40. package/test/worker-io-helper.test.ts +0 -97
  41. package/test/worker-skills.test.ts +0 -12
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # ClawSpec
2
2
 
3
+ <p align="center">
4
+ <a href="https://linux.do" target="_blank">
5
+ <img src="https://img.shields.io/badge/LINUX-DO-FFB003?style=for-the-badge&logo=linux&logoColor=white" alt="LINUX DO" />
6
+ </a>
7
+ </p>
8
+
3
9
  [Chinese (Simplified)](./README.zh-CN.md)
4
10
 
5
11
  ClawSpec is an OpenClaw plugin that embeds an OpenSpec workflow directly into chat. It splits project control and execution on purpose:
package/README.zh-CN.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # ClawSpec
2
2
 
3
+ <p align="center">
4
+ <a href="https://linux.do" target="_blank">
5
+ <img src="https://img.shields.io/badge/LINUX-DO-FFB003?style=for-the-badge&logo=linux&logoColor=white" alt="LINUX DO" />
6
+ </a>
7
+ </p>
8
+
3
9
  [English](./README.md)
4
10
 
5
11
  ClawSpec 是一个把 OpenSpec 工作流嵌入 OpenClaw 聊天窗口的插件。它有意把“项目控制”和“执行触发”分成两层:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawspec",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
6
6
  "keywords": [
@@ -23,7 +23,6 @@
23
23
  "index.ts",
24
24
  "src",
25
25
  "skills",
26
- "test",
27
26
  "README.md",
28
27
  "README.zh-CN.md",
29
28
  "openclaw.plugin.json",
@@ -0,0 +1,128 @@
1
+ export type BootstrapDependency = "openspec" | "acpx" | "service";
2
+ export type BootstrapPhase = "initializing" | "checking" | "installing" | "starting" | "ready";
3
+ export type BootstrapStatus = "idle" | "running" | "ready" | "failed";
4
+
5
+ export type BootstrapSnapshot = {
6
+ status: BootstrapStatus;
7
+ attempt: number;
8
+ updatedAt: string;
9
+ dependency?: BootstrapDependency;
10
+ phase?: BootstrapPhase;
11
+ detail?: string;
12
+ error?: string;
13
+ };
14
+
15
+ export type BootstrapProgress = {
16
+ dependency?: BootstrapDependency;
17
+ phase: BootstrapPhase;
18
+ detail: string;
19
+ };
20
+
21
+ export class BootstrapCoordinator {
22
+ private readonly runner: (report: (progress: BootstrapProgress) => void | Promise<void>) => Promise<void>;
23
+ private readonly onFailure?: (error: unknown) => void;
24
+ private snapshot: BootstrapSnapshot = {
25
+ status: "idle",
26
+ attempt: 0,
27
+ updatedAt: new Date(0).toISOString(),
28
+ };
29
+
30
+ private inFlight?: Promise<void>;
31
+
32
+ constructor(
33
+ runner: (report: (progress: BootstrapProgress) => void | Promise<void>) => Promise<void>,
34
+ onFailure?: (error: unknown) => void,
35
+ ) {
36
+ this.runner = runner;
37
+ this.onFailure = onFailure;
38
+ }
39
+
40
+ getSnapshot(): BootstrapSnapshot {
41
+ return { ...this.snapshot };
42
+ }
43
+
44
+ async start(): Promise<void> {
45
+ if (this.snapshot.status === "ready") {
46
+ return;
47
+ }
48
+ if (this.inFlight) {
49
+ return await this.inFlight;
50
+ }
51
+
52
+ const attempt = this.snapshot.attempt + 1;
53
+ this.snapshot = {
54
+ status: "running",
55
+ attempt,
56
+ updatedAt: new Date().toISOString(),
57
+ phase: "initializing",
58
+ detail: "Initializing ClawSpec bootstrap.",
59
+ };
60
+
61
+ const report = async (progress: BootstrapProgress) => {
62
+ this.snapshot = {
63
+ status: "running",
64
+ attempt,
65
+ updatedAt: new Date().toISOString(),
66
+ dependency: progress.dependency,
67
+ phase: progress.phase,
68
+ detail: progress.detail,
69
+ };
70
+ };
71
+
72
+ this.inFlight = (async () => {
73
+ try {
74
+ await this.runner(report);
75
+ this.snapshot = {
76
+ status: "ready",
77
+ attempt,
78
+ updatedAt: new Date().toISOString(),
79
+ phase: "ready",
80
+ detail: "ClawSpec dependencies are ready.",
81
+ };
82
+ } catch (error) {
83
+ this.snapshot = {
84
+ status: "failed",
85
+ attempt,
86
+ updatedAt: new Date().toISOString(),
87
+ dependency: this.snapshot.dependency,
88
+ phase: this.snapshot.phase,
89
+ detail: this.snapshot.detail,
90
+ error: error instanceof Error ? error.message : String(error),
91
+ };
92
+ this.onFailure?.(error);
93
+ } finally {
94
+ this.inFlight = undefined;
95
+ }
96
+ })();
97
+
98
+ return await this.inFlight;
99
+ }
100
+
101
+ startInBackground(): void {
102
+ void this.start();
103
+ }
104
+
105
+ reset(): void {
106
+ this.inFlight = undefined;
107
+ this.snapshot = {
108
+ status: "idle",
109
+ attempt: 0,
110
+ updatedAt: new Date().toISOString(),
111
+ };
112
+ }
113
+ }
114
+
115
+ export function buildBootstrapPendingMessage(snapshot: BootstrapSnapshot): string {
116
+ const detail = snapshot.detail?.trim() || "ClawSpec is preparing required dependencies.";
117
+ return `${detail} Try again in a moment.`;
118
+ }
119
+
120
+ export function buildBootstrapFailureMessage(snapshot: BootstrapSnapshot): string {
121
+ const detail = snapshot.detail?.trim() || "ClawSpec dependency bootstrap failed.";
122
+ const error = snapshot.error?.trim();
123
+ return [
124
+ `${detail} Bootstrap failed.`,
125
+ error ? `Reason: ${error}` : undefined,
126
+ "Retrying dependency bootstrap in the background now. Try again in a moment.",
127
+ ].filter(Boolean).join("\n");
128
+ }
@@ -25,6 +25,7 @@ export type EnsureAcpxCliOptions = {
25
25
  runner?: CommandRunner;
26
26
  expectedVersion?: string;
27
27
  runtimeEntrypoint?: string;
28
+ onInstallStart?: (info: { packageName: string; reason: string; expectedVersion: string }) => void | Promise<void>;
28
29
  };
29
30
 
30
31
  export type EnsureAcpxCliResult = {
@@ -104,6 +105,11 @@ export async function ensureAcpxCli(
104
105
  options.logger?.warn?.(
105
106
  `[clawspec] acpx CLI not ready (${globalCheck.message}); installing plugin-local ${ACPX_PACKAGE_NAME}@${expectedVersion}`,
106
107
  );
108
+ await options.onInstallStart?.({
109
+ packageName: ACPX_PACKAGE_NAME,
110
+ reason: globalCheck.message,
111
+ expectedVersion,
112
+ });
107
113
 
108
114
  const install = await runner({
109
115
  command: "npm",
@@ -22,6 +22,7 @@ export type EnsureOpenSpecCliOptions = {
22
22
  logger?: PluginLogger;
23
23
  env?: NodeJS.ProcessEnv;
24
24
  runner?: CommandRunner;
25
+ onInstallStart?: (info: { packageName: string; reason: string }) => void | Promise<void>;
25
26
  };
26
27
 
27
28
  export type EnsureOpenSpecCliResult = {
@@ -72,6 +73,10 @@ export async function ensureOpenSpecCli(
72
73
  options.logger?.warn?.(
73
74
  `[clawspec] openspec CLI not ready (${globalCheck.message}); installing plugin-local ${OPENSPEC_PACKAGE_NAME}`,
74
75
  );
76
+ await options.onInstallStart?.({
77
+ packageName: OPENSPEC_PACKAGE_NAME,
78
+ reason: globalCheck.message,
79
+ });
75
80
 
76
81
  const install = await runner({
77
82
  command: "npm",
package/src/index.ts CHANGED
@@ -19,6 +19,11 @@ import { WatcherManager } from "./watchers/manager.ts";
19
19
  import { ensureOpenSpecCli } from "./dependencies/openspec.ts";
20
20
  import { ensureAcpxCli } from "./dependencies/acpx.ts";
21
21
  import { getConfiguredDefaultWorkerAgent } from "./acp/openclaw-config.ts";
22
+ import {
23
+ BootstrapCoordinator,
24
+ buildBootstrapFailureMessage,
25
+ buildBootstrapPendingMessage,
26
+ } from "./bootstrap/state.ts";
22
27
 
23
28
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
24
29
  const LOCAL_BIN_DIR = path.join(PLUGIN_ROOT, "node_modules", ".bin");
@@ -51,6 +56,106 @@ const plugin = {
51
56
  });
52
57
  let watcherManager: WatcherManager | undefined;
53
58
  let service: ClawSpecService | undefined;
59
+ const bootstrap = new BootstrapCoordinator(
60
+ async (report) => {
61
+ let nextWatcherManager: WatcherManager | undefined;
62
+ try {
63
+ service = undefined;
64
+ watcherManager = undefined;
65
+
66
+ await report({
67
+ phase: "initializing",
68
+ detail: "ClawSpec is initializing local state.",
69
+ });
70
+ await ensureDir(pluginStateRoot);
71
+ await ensureDir(config.defaultWorkspace);
72
+ await initStores();
73
+
74
+ await report({
75
+ dependency: "openspec",
76
+ phase: "checking",
77
+ detail: "ClawSpec is checking the OpenSpec CLI.",
78
+ });
79
+ await ensureOpenSpecCli({
80
+ pluginRoot: PLUGIN_ROOT,
81
+ logger: api.logger,
82
+ onInstallStart: async ({ packageName, reason }) => {
83
+ await report({
84
+ dependency: "openspec",
85
+ phase: "installing",
86
+ detail: `ClawSpec is installing ${packageName} because OpenSpec is unavailable (${reason}).`,
87
+ });
88
+ },
89
+ });
90
+
91
+ await report({
92
+ dependency: "acpx",
93
+ phase: "checking",
94
+ detail: "ClawSpec is checking the ACPX CLI.",
95
+ });
96
+ const acpx = await ensureAcpxCli({
97
+ pluginRoot: PLUGIN_ROOT,
98
+ logger: api.logger,
99
+ onInstallStart: async ({ packageName, reason, expectedVersion }) => {
100
+ await report({
101
+ dependency: "acpx",
102
+ phase: "installing",
103
+ detail: `ClawSpec is installing ${packageName}@${expectedVersion} because no compatible ACPX CLI is available (${reason}).`,
104
+ });
105
+ },
106
+ });
107
+
108
+ await report({
109
+ dependency: "service",
110
+ phase: "starting",
111
+ detail: "ClawSpec dependencies are ready. Starting services.",
112
+ });
113
+ const configuredDefaultWorkerAgent = getConfiguredDefaultWorkerAgent(api.config) ?? "codex";
114
+ const acpClient = new AcpWorkerClient({
115
+ agentId: configuredDefaultWorkerAgent,
116
+ logger: api.logger,
117
+ command: acpx.command,
118
+ env: acpx.env,
119
+ });
120
+ nextWatcherManager = new WatcherManager({
121
+ stateStore,
122
+ openSpec,
123
+ archiveDirName: config.archiveDirName,
124
+ logger: api.logger,
125
+ notifier,
126
+ acpClient,
127
+ pollIntervalMs: config.watcherPollIntervalMs,
128
+ });
129
+ const nextService = new ClawSpecService({
130
+ api,
131
+ config: api.config,
132
+ logger: api.logger,
133
+ stateStore,
134
+ memoryStore,
135
+ openSpec,
136
+ archiveDirName: config.archiveDirName,
137
+ allowedChannels: config.allowedChannels,
138
+ defaultWorkspace: config.defaultWorkspace,
139
+ defaultWorkerAgentId: undefined,
140
+ workspaceStore,
141
+ watcherManager: nextWatcherManager,
142
+ });
143
+ await nextWatcherManager.start();
144
+ watcherManager = nextWatcherManager;
145
+ service = nextService;
146
+ } catch (error) {
147
+ service = undefined;
148
+ await nextWatcherManager?.stop();
149
+ watcherManager = undefined;
150
+ throw error;
151
+ }
152
+ },
153
+ (error) => {
154
+ api.logger.error?.(
155
+ `[clawspec] bootstrap failed: ${error instanceof Error ? error.message : String(error)}`,
156
+ );
157
+ },
158
+ );
54
159
 
55
160
  const initStores = () => Promise.all([
56
161
  stateStore.initialize(),
@@ -61,51 +166,13 @@ const plugin = {
61
166
  api.registerService({
62
167
  id: "clawspec.bootstrap",
63
168
  async start() {
64
- await ensureDir(pluginStateRoot);
65
- await ensureDir(config.defaultWorkspace);
66
- await initStores();
67
- await ensureOpenSpecCli({
68
- pluginRoot: PLUGIN_ROOT,
69
- logger: api.logger,
70
- });
71
- const acpx = await ensureAcpxCli({
72
- pluginRoot: PLUGIN_ROOT,
73
- logger: api.logger,
74
- });
75
- const configuredDefaultWorkerAgent = getConfiguredDefaultWorkerAgent(api.config) ?? "codex";
76
- const acpClient = new AcpWorkerClient({
77
- agentId: configuredDefaultWorkerAgent,
78
- logger: api.logger,
79
- command: acpx.command,
80
- env: acpx.env,
81
- });
82
- watcherManager = new WatcherManager({
83
- stateStore,
84
- openSpec,
85
- archiveDirName: config.archiveDirName,
86
- logger: api.logger,
87
- notifier,
88
- acpClient,
89
- pollIntervalMs: config.watcherPollIntervalMs,
90
- });
91
- service = new ClawSpecService({
92
- api,
93
- config: api.config,
94
- logger: api.logger,
95
- stateStore,
96
- memoryStore,
97
- openSpec,
98
- archiveDirName: config.archiveDirName,
99
- allowedChannels: config.allowedChannels,
100
- defaultWorkspace: config.defaultWorkspace,
101
- defaultWorkerAgentId: undefined,
102
- workspaceStore,
103
- watcherManager,
104
- });
105
- await watcherManager.start();
169
+ await bootstrap.start();
106
170
  },
107
171
  async stop() {
108
172
  await watcherManager?.stop();
173
+ watcherManager = undefined;
174
+ service = undefined;
175
+ bootstrap.reset();
109
176
  },
110
177
  });
111
178
 
@@ -137,9 +204,24 @@ const plugin = {
137
204
  handler: async (ctx) => {
138
205
  await initStores();
139
206
  if (!service) {
207
+ const snapshot = bootstrap.getSnapshot();
208
+ if (snapshot.status === "failed") {
209
+ bootstrap.startInBackground();
210
+ return {
211
+ ok: false,
212
+ text: buildBootstrapFailureMessage(snapshot),
213
+ };
214
+ }
215
+ if (snapshot.status === "idle") {
216
+ bootstrap.startInBackground();
217
+ return {
218
+ ok: false,
219
+ text: buildBootstrapPendingMessage(bootstrap.getSnapshot()),
220
+ };
221
+ }
140
222
  return {
141
223
  ok: false,
142
- text: "ClawSpec is still bootstrapping dependencies. Try again in a moment.",
224
+ text: buildBootstrapPendingMessage(snapshot),
143
225
  };
144
226
  }
145
227
  const subcommand = parseSubcommand(ctx.args);
@@ -2795,7 +2795,7 @@ function parseWorkerProgressEvent(line: string): WorkerProgressEvent | undefined
2795
2795
 
2796
2796
  function formatWorkerProgressMessage(project: ProjectState, event: WorkerProgressEvent): string | undefined {
2797
2797
  const rawMessage = typeof event.message === "string" ? event.message : "";
2798
- const message = shortenActivityText(rawMessage, 120);
2798
+ const message = shortenActivityText(compactWorkerProgressDisplayPaths(project, rawMessage), 120);
2799
2799
  if (!message) {
2800
2800
  return undefined;
2801
2801
  }
@@ -2916,6 +2916,74 @@ function compactProjectLabel(project: ProjectState): string {
2916
2916
  return `${projectName}-${changeName}`;
2917
2917
  }
2918
2918
 
2919
+ function compactWorkerProgressDisplayPaths(project: ProjectState, text: string): string {
2920
+ try {
2921
+ const compactRoot = compactWorkerProgressRoot(project);
2922
+ if (!compactRoot) {
2923
+ return text;
2924
+ }
2925
+
2926
+ let compacted = text;
2927
+ if (project.changeDir) {
2928
+ compacted = replaceDisplayPathPrefix(compacted, project.changeDir, `${compactRoot}:`);
2929
+ }
2930
+ if (project.repoPath) {
2931
+ compacted = replaceDisplayPathPrefix(compacted, project.repoPath, `${compactRoot}:`);
2932
+ }
2933
+ compacted = normalizeCompactedDisplayPaths(compacted, compactRoot);
2934
+ return compacted.length < text.length ? compacted : text;
2935
+ } catch {
2936
+ return text;
2937
+ }
2938
+ }
2939
+
2940
+ function compactWorkerProgressRoot(project: ProjectState): string | undefined {
2941
+ const changeName = project.changeName?.trim();
2942
+ if (!changeName) {
2943
+ return undefined;
2944
+ }
2945
+ const projectName = project.projectName?.trim()
2946
+ || (project.repoPath ? path.basename(project.repoPath) : undefined)
2947
+ || "project";
2948
+ return `${projectName}@${changeName}`;
2949
+ }
2950
+
2951
+ function replaceDisplayPathPrefix(text: string, targetPath: string, replacement: string): string {
2952
+ const pattern = buildDisplayPathPrefixPattern(targetPath);
2953
+ if (!pattern) {
2954
+ return text;
2955
+ }
2956
+ return text.replace(pattern, replacement);
2957
+ }
2958
+
2959
+ function normalizeCompactedDisplayPaths(text: string, compactRoot: string): string {
2960
+ const prefix = escapeRegExp(`${compactRoot}:`);
2961
+ const pattern = new RegExp("(" + prefix + ")([^\\s\"'`,)\\]}]+)", "g");
2962
+ return text.replace(pattern, (_match, prefix: string, suffix: string) => `${prefix}${suffix.replace(/\\/g, "/")}`);
2963
+ }
2964
+
2965
+ function buildDisplayPathPrefixPattern(targetPath: string): RegExp | undefined {
2966
+ const normalized = normalizeSlashes(targetPath).replace(/\/+$/, "");
2967
+ if (!normalized) {
2968
+ return undefined;
2969
+ }
2970
+
2971
+ const escaped = normalized
2972
+ .split("/")
2973
+ .map((segment) => escapeRegExp(segment))
2974
+ .join("[/\\\\]+");
2975
+ if (!escaped) {
2976
+ return undefined;
2977
+ }
2978
+
2979
+ const flags = /^[A-Za-z]:/.test(normalized) ? "gi" : "g";
2980
+ return new RegExp(`${escaped}(?:[/\\\\]+)?`, flags);
2981
+ }
2982
+
2983
+ function escapeRegExp(value: string): string {
2984
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2985
+ }
2986
+
2919
2987
  function compactProgressMarker(current?: number, total?: number): string {
2920
2988
  if (!total || total <= 0 || !current || current <= 0) {
2921
2989
  return "";