clawspec 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +908 -0
- package/README.zh-CN.md +914 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +129 -0
- package/package.json +52 -0
- package/skills/openspec-apply-change.md +146 -0
- package/skills/openspec-explore.md +75 -0
- package/skills/openspec-propose.md +102 -0
- package/src/acp/client.ts +693 -0
- package/src/config.ts +220 -0
- package/src/control/keywords.ts +72 -0
- package/src/dependencies/acpx.ts +221 -0
- package/src/dependencies/openspec.ts +148 -0
- package/src/execution/session.ts +56 -0
- package/src/execution/state.ts +125 -0
- package/src/index.ts +179 -0
- package/src/memory/store.ts +118 -0
- package/src/openspec/cli.ts +279 -0
- package/src/openspec/tasks.ts +40 -0
- package/src/orchestrator/helpers.ts +312 -0
- package/src/orchestrator/service.ts +2971 -0
- package/src/planning/journal.ts +118 -0
- package/src/rollback/store.ts +173 -0
- package/src/state/locks.ts +133 -0
- package/src/state/store.ts +527 -0
- package/src/types.ts +301 -0
- package/src/utils/args.ts +88 -0
- package/src/utils/channel-key.ts +66 -0
- package/src/utils/env-path.ts +31 -0
- package/src/utils/fs.ts +218 -0
- package/src/utils/markdown.ts +136 -0
- package/src/utils/messages.ts +5 -0
- package/src/utils/paths.ts +127 -0
- package/src/utils/shell-command.ts +227 -0
- package/src/utils/slug.ts +50 -0
- package/src/watchers/manager.ts +3042 -0
- package/src/watchers/notifier.ts +69 -0
- package/src/worker/prompts.ts +484 -0
- package/src/worker/skills.ts +52 -0
- package/src/workspace/store.ts +140 -0
- package/test/acp-client.test.ts +234 -0
- package/test/acpx-dependency.test.ts +112 -0
- package/test/assistant-journal.test.ts +136 -0
- package/test/command-surface.test.ts +23 -0
- package/test/config.test.ts +77 -0
- package/test/detach-attach.test.ts +98 -0
- package/test/file-lock.test.ts +78 -0
- package/test/fs-utils.test.ts +22 -0
- package/test/helpers/harness.ts +241 -0
- package/test/helpers.test.ts +108 -0
- package/test/keywords.test.ts +80 -0
- package/test/notifier.test.ts +29 -0
- package/test/openspec-dependency.test.ts +67 -0
- package/test/pause-cancel.test.ts +55 -0
- package/test/planning-journal.test.ts +69 -0
- package/test/plugin-registration.test.ts +35 -0
- package/test/project-memory.test.ts +42 -0
- package/test/proposal.test.ts +24 -0
- package/test/queue-planning.test.ts +247 -0
- package/test/queue-work.test.ts +110 -0
- package/test/recovery.test.ts +576 -0
- package/test/service-archive.test.ts +82 -0
- package/test/shell-command.test.ts +48 -0
- package/test/state-store.test.ts +74 -0
- package/test/tasks-and-checkpoint.test.ts +60 -0
- package/test/use-project.test.ts +19 -0
- package/test/watcher-planning.test.ts +504 -0
- package/test/watcher-work.test.ts +1741 -0
- package/test/worker-command.test.ts +66 -0
- package/test/worker-skills.test.ts +12 -0
- package/tsconfig.json +25 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getDefaultWorkspacePath,
|
|
3
|
+
resolveUserPath,
|
|
4
|
+
} from "./utils/paths.ts";
|
|
5
|
+
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
6
|
+
|
|
7
|
+
export type ClawSpecPluginConfig = {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
allowedChannels?: string[];
|
|
10
|
+
openSpecTimeoutMs: number;
|
|
11
|
+
maxAutoContinueTurns: number;
|
|
12
|
+
maxNoProgressTurns: number;
|
|
13
|
+
workerWaitTimeoutMs: number;
|
|
14
|
+
workerAgentId: string;
|
|
15
|
+
workerBackendId?: string;
|
|
16
|
+
watcherPollIntervalMs: number;
|
|
17
|
+
subagentLane?: string;
|
|
18
|
+
archiveDirName: string;
|
|
19
|
+
defaultWorkspace: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const clawspecPluginConfigSchema: OpenClawPluginConfigSchema = {
|
|
23
|
+
jsonSchema: {
|
|
24
|
+
type: "object",
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
properties: {
|
|
27
|
+
enabled: { type: "boolean" },
|
|
28
|
+
allowedChannels: {
|
|
29
|
+
type: "array",
|
|
30
|
+
items: { type: "string" },
|
|
31
|
+
},
|
|
32
|
+
maxAutoContinueTurns: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
minimum: 1,
|
|
35
|
+
maximum: 50,
|
|
36
|
+
},
|
|
37
|
+
maxNoProgressTurns: {
|
|
38
|
+
type: "integer",
|
|
39
|
+
minimum: 1,
|
|
40
|
+
maximum: 10,
|
|
41
|
+
},
|
|
42
|
+
openSpecTimeoutMs: {
|
|
43
|
+
type: "integer",
|
|
44
|
+
minimum: 5_000,
|
|
45
|
+
maximum: 600_000,
|
|
46
|
+
},
|
|
47
|
+
workerWaitTimeoutMs: {
|
|
48
|
+
type: "integer",
|
|
49
|
+
minimum: 10_000,
|
|
50
|
+
maximum: 3_600_000,
|
|
51
|
+
},
|
|
52
|
+
workerAgentId: {
|
|
53
|
+
type: "string",
|
|
54
|
+
},
|
|
55
|
+
workerBackendId: {
|
|
56
|
+
type: "string",
|
|
57
|
+
},
|
|
58
|
+
watcherPollIntervalMs: {
|
|
59
|
+
type: "integer",
|
|
60
|
+
minimum: 1_000,
|
|
61
|
+
maximum: 60_000,
|
|
62
|
+
},
|
|
63
|
+
subagentLane: {
|
|
64
|
+
type: "string",
|
|
65
|
+
},
|
|
66
|
+
archiveDirName: {
|
|
67
|
+
type: "string",
|
|
68
|
+
},
|
|
69
|
+
defaultWorkspace: {
|
|
70
|
+
type: "string",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
validate(value) {
|
|
75
|
+
const parsed = parsePluginConfig((value ?? {}) as Record<string, unknown>);
|
|
76
|
+
return { ok: true, value: parsed };
|
|
77
|
+
},
|
|
78
|
+
uiHints: {
|
|
79
|
+
enabled: {
|
|
80
|
+
label: "Enable ClawSpec",
|
|
81
|
+
help: "Enable or disable the ClawSpec plugin",
|
|
82
|
+
},
|
|
83
|
+
allowedChannels: {
|
|
84
|
+
label: "Allowed Channels",
|
|
85
|
+
help: "Optional list of channel ids allowed to use /clawspec",
|
|
86
|
+
},
|
|
87
|
+
maxAutoContinueTurns: {
|
|
88
|
+
label: "Deprecated Auto Turns",
|
|
89
|
+
help: "Deprecated no-op retained for backward compatibility with older configs",
|
|
90
|
+
advanced: true,
|
|
91
|
+
},
|
|
92
|
+
maxNoProgressTurns: {
|
|
93
|
+
label: "Deprecated No Progress",
|
|
94
|
+
help: "Deprecated no-op retained for backward compatibility with older configs",
|
|
95
|
+
advanced: true,
|
|
96
|
+
},
|
|
97
|
+
openSpecTimeoutMs: {
|
|
98
|
+
label: "OpenSpec Timeout",
|
|
99
|
+
help: "Timeout in milliseconds for each OpenSpec command",
|
|
100
|
+
advanced: true,
|
|
101
|
+
},
|
|
102
|
+
workerWaitTimeoutMs: {
|
|
103
|
+
label: "Deprecated Worker Timeout",
|
|
104
|
+
help: "Deprecated no-op retained for backward compatibility with older configs",
|
|
105
|
+
advanced: true,
|
|
106
|
+
},
|
|
107
|
+
workerAgentId: {
|
|
108
|
+
label: "Worker Agent",
|
|
109
|
+
help: "Agent id used for background ACP planning and implementation turns",
|
|
110
|
+
advanced: true,
|
|
111
|
+
},
|
|
112
|
+
workerBackendId: {
|
|
113
|
+
label: "Deprecated Worker Backend",
|
|
114
|
+
help: "Deprecated no-op retained for backward compatibility with older configs",
|
|
115
|
+
advanced: true,
|
|
116
|
+
},
|
|
117
|
+
watcherPollIntervalMs: {
|
|
118
|
+
label: "Watcher Poll Interval",
|
|
119
|
+
help: "Background watcher recovery poll interval in milliseconds",
|
|
120
|
+
advanced: true,
|
|
121
|
+
},
|
|
122
|
+
subagentLane: {
|
|
123
|
+
label: "Deprecated Lane Hint",
|
|
124
|
+
help: "Deprecated no-op retained for backward compatibility with older configs",
|
|
125
|
+
advanced: true,
|
|
126
|
+
},
|
|
127
|
+
archiveDirName: {
|
|
128
|
+
label: "Archive Folder",
|
|
129
|
+
help: "Directory name for archived project bundles",
|
|
130
|
+
advanced: true,
|
|
131
|
+
},
|
|
132
|
+
defaultWorkspace: {
|
|
133
|
+
label: "Default Workspace",
|
|
134
|
+
help: "Default workspace used for `/clawspec workspace` and `/clawspec use`",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const DEFAULT_CONFIG: ClawSpecPluginConfig = {
|
|
140
|
+
enabled: true,
|
|
141
|
+
maxAutoContinueTurns: 3,
|
|
142
|
+
maxNoProgressTurns: 2,
|
|
143
|
+
openSpecTimeoutMs: 120_000,
|
|
144
|
+
workerWaitTimeoutMs: 300_000,
|
|
145
|
+
workerAgentId: "codex",
|
|
146
|
+
watcherPollIntervalMs: 4_000,
|
|
147
|
+
archiveDirName: "archives",
|
|
148
|
+
defaultWorkspace: getDefaultWorkspacePath(),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export function parsePluginConfig(
|
|
152
|
+
value: Record<string, unknown> | undefined,
|
|
153
|
+
): ClawSpecPluginConfig {
|
|
154
|
+
const config = value ?? {};
|
|
155
|
+
return {
|
|
156
|
+
enabled: asBoolean(config.enabled, DEFAULT_CONFIG.enabled),
|
|
157
|
+
allowedChannels: Array.isArray(config.allowedChannels)
|
|
158
|
+
? config.allowedChannels.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
159
|
+
: undefined,
|
|
160
|
+
maxAutoContinueTurns: asInt(
|
|
161
|
+
config.maxAutoContinueTurns,
|
|
162
|
+
DEFAULT_CONFIG.maxAutoContinueTurns,
|
|
163
|
+
1,
|
|
164
|
+
50,
|
|
165
|
+
),
|
|
166
|
+
maxNoProgressTurns: asInt(
|
|
167
|
+
config.maxNoProgressTurns,
|
|
168
|
+
DEFAULT_CONFIG.maxNoProgressTurns,
|
|
169
|
+
1,
|
|
170
|
+
10,
|
|
171
|
+
),
|
|
172
|
+
openSpecTimeoutMs: asInt(
|
|
173
|
+
config.openSpecTimeoutMs,
|
|
174
|
+
DEFAULT_CONFIG.openSpecTimeoutMs,
|
|
175
|
+
5_000,
|
|
176
|
+
600_000,
|
|
177
|
+
),
|
|
178
|
+
workerWaitTimeoutMs: asInt(
|
|
179
|
+
config.workerWaitTimeoutMs,
|
|
180
|
+
DEFAULT_CONFIG.workerWaitTimeoutMs,
|
|
181
|
+
10_000,
|
|
182
|
+
3_600_000,
|
|
183
|
+
),
|
|
184
|
+
workerAgentId: asOptionalString(config.workerAgentId) ?? DEFAULT_CONFIG.workerAgentId,
|
|
185
|
+
workerBackendId: asOptionalString(config.workerBackendId),
|
|
186
|
+
watcherPollIntervalMs: asInt(
|
|
187
|
+
config.watcherPollIntervalMs,
|
|
188
|
+
DEFAULT_CONFIG.watcherPollIntervalMs,
|
|
189
|
+
1_000,
|
|
190
|
+
60_000,
|
|
191
|
+
),
|
|
192
|
+
subagentLane: asOptionalString(config.subagentLane),
|
|
193
|
+
archiveDirName: asOptionalString(config.archiveDirName) ?? DEFAULT_CONFIG.archiveDirName,
|
|
194
|
+
defaultWorkspace: resolveUserPath(
|
|
195
|
+
asOptionalString(config.defaultWorkspace) ?? DEFAULT_CONFIG.defaultWorkspace,
|
|
196
|
+
),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
201
|
+
return typeof value === "boolean" ? value : fallback;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function asOptionalString(value: unknown): string | undefined {
|
|
205
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function asInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
209
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
210
|
+
return fallback;
|
|
211
|
+
}
|
|
212
|
+
const intValue = Math.trunc(value);
|
|
213
|
+
if (intValue < min) {
|
|
214
|
+
return min;
|
|
215
|
+
}
|
|
216
|
+
if (intValue > max) {
|
|
217
|
+
return max;
|
|
218
|
+
}
|
|
219
|
+
return intValue;
|
|
220
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type ClawSpecKeywordKind =
|
|
2
|
+
| "plan"
|
|
3
|
+
| "work"
|
|
4
|
+
| "attach"
|
|
5
|
+
| "detach"
|
|
6
|
+
| "pause"
|
|
7
|
+
| "continue"
|
|
8
|
+
| "status"
|
|
9
|
+
| "cancel";
|
|
10
|
+
|
|
11
|
+
export type ClawSpecKeywordIntent = {
|
|
12
|
+
kind: ClawSpecKeywordKind;
|
|
13
|
+
command: string;
|
|
14
|
+
args: string;
|
|
15
|
+
raw: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const COMMAND_ALIASES: Record<string, ClawSpecKeywordKind> = {
|
|
19
|
+
"cs-plan": "plan",
|
|
20
|
+
"cs-work": "work",
|
|
21
|
+
"cs-attach": "attach",
|
|
22
|
+
"cs-detach": "detach",
|
|
23
|
+
"cs-deattach": "detach",
|
|
24
|
+
"cs-pause": "pause",
|
|
25
|
+
"cs-continue": "continue",
|
|
26
|
+
"cs-status": "status",
|
|
27
|
+
"cs-cancel": "cancel",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function parseClawSpecKeyword(text: string): ClawSpecKeywordIntent | null {
|
|
31
|
+
const trimmed = text.trim();
|
|
32
|
+
if (!trimmed.toLowerCase().startsWith("cs-")) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const firstWhitespace = trimmed.search(/\s/);
|
|
37
|
+
const rawCommand = firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace);
|
|
38
|
+
const kind = COMMAND_ALIASES[rawCommand.toLowerCase()];
|
|
39
|
+
if (!kind) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
kind,
|
|
45
|
+
command: rawCommand.toLowerCase(),
|
|
46
|
+
args: firstWhitespace === -1 ? "" : trimmed.slice(firstWhitespace + 1).trim(),
|
|
47
|
+
raw: trimmed,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isClawSpecKeywordText(text: string): boolean {
|
|
52
|
+
return parseClawSpecKeyword(text) !== null;
|
|
53
|
+
}
|
|
54
|
+
|
|
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
|
+
export function extractEmbeddedClawSpecKeyword(text: string): ClawSpecKeywordIntent | null {
|
|
61
|
+
const direct = parseClawSpecKeyword(text);
|
|
62
|
+
if (direct) {
|
|
63
|
+
return direct;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const embeddedMatch = text.match(EMBEDDED_KEYWORD_PATTERN);
|
|
67
|
+
if (!embeddedMatch?.[1]) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return parseClawSpecKeyword(embeddedMatch[1]);
|
|
72
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
3
|
+
import { prependPathEntries } from "../utils/env-path.ts";
|
|
4
|
+
import {
|
|
5
|
+
describeCommandFailure,
|
|
6
|
+
isMissingCommandResult,
|
|
7
|
+
runShellCommand,
|
|
8
|
+
type ShellCommandResult,
|
|
9
|
+
} from "../utils/shell-command.ts";
|
|
10
|
+
|
|
11
|
+
export const ACPX_PACKAGE_NAME = "acpx";
|
|
12
|
+
export const ACPX_EXPECTED_VERSION = "0.3.1";
|
|
13
|
+
|
|
14
|
+
type CommandRunner = (params: {
|
|
15
|
+
command: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
cwd: string;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
}) => Promise<ShellCommandResult>;
|
|
20
|
+
|
|
21
|
+
export type EnsureAcpxCliOptions = {
|
|
22
|
+
pluginRoot: string;
|
|
23
|
+
logger?: PluginLogger;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
runner?: CommandRunner;
|
|
26
|
+
expectedVersion?: string;
|
|
27
|
+
runtimeEntrypoint?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type EnsureAcpxCliResult = {
|
|
31
|
+
source: "local" | "builtin" | "global";
|
|
32
|
+
version: string;
|
|
33
|
+
localBinDir: string;
|
|
34
|
+
command: string;
|
|
35
|
+
env: NodeJS.ProcessEnv;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function ensureAcpxCli(
|
|
39
|
+
options: EnsureAcpxCliOptions,
|
|
40
|
+
): Promise<EnsureAcpxCliResult> {
|
|
41
|
+
const runner = options.runner ?? runCommand;
|
|
42
|
+
const expectedVersion = options.expectedVersion?.trim() || ACPX_EXPECTED_VERSION;
|
|
43
|
+
const localBinDir = path.join(options.pluginRoot, "node_modules", ".bin");
|
|
44
|
+
const localCommand = path.join(
|
|
45
|
+
localBinDir,
|
|
46
|
+
process.platform === "win32" ? "acpx.cmd" : "acpx",
|
|
47
|
+
);
|
|
48
|
+
const env = prependPathEntries(options.env, [localBinDir]);
|
|
49
|
+
const builtinCommand = getBuiltInAcpxCommand(options.runtimeEntrypoint ?? process.argv[1]);
|
|
50
|
+
|
|
51
|
+
const localCheck = await checkAcpxVersion(runner, {
|
|
52
|
+
command: localCommand,
|
|
53
|
+
cwd: options.pluginRoot,
|
|
54
|
+
env,
|
|
55
|
+
expectedVersion,
|
|
56
|
+
});
|
|
57
|
+
if (localCheck.ok) {
|
|
58
|
+
options.logger?.info?.(`[clawspec] acpx CLI ready from plugin-local install (version ${localCheck.version})`);
|
|
59
|
+
return {
|
|
60
|
+
source: "local",
|
|
61
|
+
version: localCheck.version,
|
|
62
|
+
localBinDir,
|
|
63
|
+
command: localCommand,
|
|
64
|
+
env,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (builtinCommand) {
|
|
69
|
+
const builtinCheck = await checkAcpxVersion(runner, {
|
|
70
|
+
command: builtinCommand,
|
|
71
|
+
cwd: options.pluginRoot,
|
|
72
|
+
env,
|
|
73
|
+
expectedVersion,
|
|
74
|
+
});
|
|
75
|
+
if (builtinCheck.ok) {
|
|
76
|
+
options.logger?.info?.(`[clawspec] acpx CLI ready from OpenClaw builtin install (version ${builtinCheck.version})`);
|
|
77
|
+
return {
|
|
78
|
+
source: "builtin",
|
|
79
|
+
version: builtinCheck.version,
|
|
80
|
+
localBinDir,
|
|
81
|
+
command: builtinCommand,
|
|
82
|
+
env,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const globalCheck = await checkAcpxVersion(runner, {
|
|
88
|
+
command: "acpx",
|
|
89
|
+
cwd: options.pluginRoot,
|
|
90
|
+
env,
|
|
91
|
+
expectedVersion,
|
|
92
|
+
});
|
|
93
|
+
if (globalCheck.ok) {
|
|
94
|
+
options.logger?.info?.(`[clawspec] acpx CLI ready from PATH (version ${globalCheck.version})`);
|
|
95
|
+
return {
|
|
96
|
+
source: "global",
|
|
97
|
+
version: globalCheck.version,
|
|
98
|
+
localBinDir,
|
|
99
|
+
command: "acpx",
|
|
100
|
+
env,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
options.logger?.warn?.(
|
|
105
|
+
`[clawspec] acpx CLI not ready (${globalCheck.message}); installing plugin-local ${ACPX_PACKAGE_NAME}@${expectedVersion}`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const install = await runner({
|
|
109
|
+
command: "npm",
|
|
110
|
+
args: [
|
|
111
|
+
"install",
|
|
112
|
+
"--omit=dev",
|
|
113
|
+
"--no-save",
|
|
114
|
+
"--package-lock=false",
|
|
115
|
+
`${ACPX_PACKAGE_NAME}@${expectedVersion}`,
|
|
116
|
+
],
|
|
117
|
+
cwd: options.pluginRoot,
|
|
118
|
+
env,
|
|
119
|
+
});
|
|
120
|
+
if (install.error || (install.code ?? 0) !== 0) {
|
|
121
|
+
if (isMissingCommandResult(install, "npm")) {
|
|
122
|
+
throw new Error("npm is required to install plugin-local acpx but was not found on PATH");
|
|
123
|
+
}
|
|
124
|
+
throw new Error(
|
|
125
|
+
`failed to install plugin-local acpx: ${describeCommandFailure(install, "npm install")}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const postcheck = await checkAcpxVersion(runner, {
|
|
130
|
+
command: localCommand,
|
|
131
|
+
cwd: options.pluginRoot,
|
|
132
|
+
env,
|
|
133
|
+
expectedVersion,
|
|
134
|
+
});
|
|
135
|
+
if (!postcheck.ok) {
|
|
136
|
+
throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
options.logger?.info?.(`[clawspec] acpx plugin-local binary ready (version ${postcheck.version})`);
|
|
140
|
+
return {
|
|
141
|
+
source: "local",
|
|
142
|
+
version: postcheck.version,
|
|
143
|
+
localBinDir,
|
|
144
|
+
command: localCommand,
|
|
145
|
+
env,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function checkAcpxVersion(
|
|
150
|
+
runner: CommandRunner,
|
|
151
|
+
params: {
|
|
152
|
+
command: string;
|
|
153
|
+
cwd: string;
|
|
154
|
+
env?: NodeJS.ProcessEnv;
|
|
155
|
+
expectedVersion: string;
|
|
156
|
+
},
|
|
157
|
+
): Promise<
|
|
158
|
+
| { ok: true; version: string }
|
|
159
|
+
| { ok: false; message: string }
|
|
160
|
+
> {
|
|
161
|
+
const result = await runner({
|
|
162
|
+
command: params.command,
|
|
163
|
+
args: ["--version"],
|
|
164
|
+
cwd: params.cwd,
|
|
165
|
+
env: params.env,
|
|
166
|
+
});
|
|
167
|
+
if (result.error || (result.code ?? 0) !== 0) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
message: describeCommandFailure(result, params.command),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const version = `${result.stdout}\n${result.stderr}`.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/)?.[0];
|
|
175
|
+
if (!version) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
message: "acpx --version did not return a parseable version",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (version !== params.expectedVersion) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
message: `acpx version mismatch: found ${version}, expected ${params.expectedVersion}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, version };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runCommand(params: {
|
|
191
|
+
command: string;
|
|
192
|
+
args: string[];
|
|
193
|
+
cwd: string;
|
|
194
|
+
env?: NodeJS.ProcessEnv;
|
|
195
|
+
}): Promise<ShellCommandResult> {
|
|
196
|
+
return await runShellCommand(params);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getBuiltInAcpxCommand(runtimeEntrypoint: string | undefined): string | undefined {
|
|
200
|
+
if (!runtimeEntrypoint || runtimeEntrypoint === "-") {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
const entry = path.resolve(runtimeEntrypoint);
|
|
204
|
+
if (path.basename(entry) !== "index.js") {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const distDir = path.dirname(entry);
|
|
208
|
+
if (path.basename(distDir) !== "dist") {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const packageRoot = path.dirname(distDir);
|
|
212
|
+
return path.join(
|
|
213
|
+
packageRoot,
|
|
214
|
+
"dist",
|
|
215
|
+
"extensions",
|
|
216
|
+
"acpx",
|
|
217
|
+
"node_modules",
|
|
218
|
+
".bin",
|
|
219
|
+
process.platform === "win32" ? "acpx.cmd" : "acpx",
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
3
|
+
import { prependPathEntries } from "../utils/env-path.ts";
|
|
4
|
+
import {
|
|
5
|
+
describeCommandFailure,
|
|
6
|
+
isMissingCommandResult,
|
|
7
|
+
runShellCommand,
|
|
8
|
+
type ShellCommandResult,
|
|
9
|
+
} from "../utils/shell-command.ts";
|
|
10
|
+
|
|
11
|
+
export const OPENSPEC_PACKAGE_NAME = "@fission-ai/openspec";
|
|
12
|
+
|
|
13
|
+
type CommandRunner = (params: {
|
|
14
|
+
command: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
cwd: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
}) => Promise<ShellCommandResult>;
|
|
19
|
+
|
|
20
|
+
export type EnsureOpenSpecCliOptions = {
|
|
21
|
+
pluginRoot: string;
|
|
22
|
+
logger?: PluginLogger;
|
|
23
|
+
env?: NodeJS.ProcessEnv;
|
|
24
|
+
runner?: CommandRunner;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type EnsureOpenSpecCliResult = {
|
|
28
|
+
source: "local" | "global";
|
|
29
|
+
version: string;
|
|
30
|
+
localBinDir: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function ensureOpenSpecCli(
|
|
34
|
+
options: EnsureOpenSpecCliOptions,
|
|
35
|
+
): Promise<EnsureOpenSpecCliResult> {
|
|
36
|
+
const runner = options.runner ?? runCommand;
|
|
37
|
+
const localBinDir = path.join(options.pluginRoot, "node_modules", ".bin");
|
|
38
|
+
const localCommand = path.join(
|
|
39
|
+
localBinDir,
|
|
40
|
+
process.platform === "win32" ? "openspec.cmd" : "openspec",
|
|
41
|
+
);
|
|
42
|
+
const env = prependPathEntries(options.env, [localBinDir]);
|
|
43
|
+
|
|
44
|
+
const localCheck = await checkOpenSpecVersion(runner, {
|
|
45
|
+
command: localCommand,
|
|
46
|
+
cwd: options.pluginRoot,
|
|
47
|
+
env,
|
|
48
|
+
});
|
|
49
|
+
if (localCheck.ok) {
|
|
50
|
+
options.logger?.info?.(`[clawspec] openspec CLI ready from plugin-local install (version ${localCheck.version})`);
|
|
51
|
+
return {
|
|
52
|
+
source: "local",
|
|
53
|
+
version: localCheck.version,
|
|
54
|
+
localBinDir,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const globalCheck = await checkOpenSpecVersion(runner, {
|
|
59
|
+
command: "openspec",
|
|
60
|
+
cwd: options.pluginRoot,
|
|
61
|
+
env,
|
|
62
|
+
});
|
|
63
|
+
if (globalCheck.ok) {
|
|
64
|
+
options.logger?.info?.(`[clawspec] openspec CLI ready from PATH (version ${globalCheck.version})`);
|
|
65
|
+
return {
|
|
66
|
+
source: "global",
|
|
67
|
+
version: globalCheck.version,
|
|
68
|
+
localBinDir,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
options.logger?.warn?.(
|
|
73
|
+
`[clawspec] openspec CLI not found (${globalCheck.message}); installing plugin-local ${OPENSPEC_PACKAGE_NAME}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const install = await runner({
|
|
77
|
+
command: "npm",
|
|
78
|
+
args: ["install", "--omit=dev", "--no-save", "--package-lock=false", OPENSPEC_PACKAGE_NAME],
|
|
79
|
+
cwd: options.pluginRoot,
|
|
80
|
+
env,
|
|
81
|
+
});
|
|
82
|
+
if (install.error || (install.code ?? 0) !== 0) {
|
|
83
|
+
if (isMissingCommandResult(install, "npm")) {
|
|
84
|
+
throw new Error("npm is required to install plugin-local openspec but was not found on PATH");
|
|
85
|
+
}
|
|
86
|
+
throw new Error(
|
|
87
|
+
`failed to install plugin-local openspec: ${describeCommandFailure(install, "npm install")}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const postcheck = await checkOpenSpecVersion(runner, {
|
|
92
|
+
command: localCommand,
|
|
93
|
+
cwd: options.pluginRoot,
|
|
94
|
+
env,
|
|
95
|
+
});
|
|
96
|
+
if (!postcheck.ok) {
|
|
97
|
+
throw new Error(`plugin-local openspec verification failed after install: ${postcheck.message}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
options.logger?.info?.(`[clawspec] openspec plugin-local binary ready (version ${postcheck.version})`);
|
|
101
|
+
return {
|
|
102
|
+
source: "local",
|
|
103
|
+
version: postcheck.version,
|
|
104
|
+
localBinDir,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function checkOpenSpecVersion(
|
|
109
|
+
runner: CommandRunner,
|
|
110
|
+
params: {
|
|
111
|
+
command: string;
|
|
112
|
+
cwd: string;
|
|
113
|
+
env?: NodeJS.ProcessEnv;
|
|
114
|
+
},
|
|
115
|
+
): Promise<
|
|
116
|
+
| { ok: true; version: string }
|
|
117
|
+
| { ok: false; message: string }
|
|
118
|
+
> {
|
|
119
|
+
const result = await runner({
|
|
120
|
+
command: params.command,
|
|
121
|
+
args: ["--version"],
|
|
122
|
+
cwd: params.cwd,
|
|
123
|
+
env: params.env,
|
|
124
|
+
});
|
|
125
|
+
if (result.error || (result.code ?? 0) !== 0) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
message: describeCommandFailure(result, params.command),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const version = `${result.stdout}\n${result.stderr}`.trim().split(/\r?\n/).find((line) => line.trim())?.trim();
|
|
132
|
+
if (!version) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
message: "openspec --version did not return a parseable version",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, version };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runCommand(params: {
|
|
142
|
+
command: string;
|
|
143
|
+
args: string[];
|
|
144
|
+
cwd: string;
|
|
145
|
+
env?: NodeJS.ProcessEnv;
|
|
146
|
+
}): Promise<ShellCommandResult> {
|
|
147
|
+
return await runShellCommand(params);
|
|
148
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ProjectState } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
export function buildWorkerSessionKey(
|
|
4
|
+
project: ProjectState,
|
|
5
|
+
workerSlot = "primary",
|
|
6
|
+
workerAgentId?: string,
|
|
7
|
+
): string {
|
|
8
|
+
return `clawspec:${project.projectId}:${project.changeName ?? "none"}:${workerSlot}:${normalizeWorkerAgentId(workerAgentId ?? project.workerAgentId ?? "default")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createWorkerSessionKey(
|
|
12
|
+
project: ProjectState,
|
|
13
|
+
options?: {
|
|
14
|
+
workerSlot?: string;
|
|
15
|
+
workerAgentId?: string;
|
|
16
|
+
attemptKey?: string;
|
|
17
|
+
},
|
|
18
|
+
): string {
|
|
19
|
+
const base = buildWorkerSessionKey(
|
|
20
|
+
project,
|
|
21
|
+
options?.workerSlot,
|
|
22
|
+
options?.workerAgentId,
|
|
23
|
+
);
|
|
24
|
+
const attemptKey = normalizeSessionAttemptKey(options?.attemptKey);
|
|
25
|
+
return `${base}:${attemptKey}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function matchesExecutionSession(project: ProjectState, sessionKey?: string): boolean {
|
|
29
|
+
if (!sessionKey) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const executionSessionKey = project.execution?.sessionKey;
|
|
34
|
+
if (!executionSessionKey) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (executionSessionKey === sessionKey) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return executionSessionKey === project.boundSessionKey
|
|
43
|
+
&& project.boundSessionKey === sessionKey;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeWorkerAgentId(agentId: string): string {
|
|
47
|
+
return agentId.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeSessionAttemptKey(value?: string): string {
|
|
51
|
+
const normalized = (value ?? new Date().toISOString())
|
|
52
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
|
53
|
+
.replace(/^-+|-+$/g, "")
|
|
54
|
+
.slice(0, 48);
|
|
55
|
+
return normalized.length > 0 ? normalized : "run";
|
|
56
|
+
}
|