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.
Files changed (71) hide show
  1. package/README.md +908 -0
  2. package/README.zh-CN.md +914 -0
  3. package/index.ts +3 -0
  4. package/openclaw.plugin.json +129 -0
  5. package/package.json +52 -0
  6. package/skills/openspec-apply-change.md +146 -0
  7. package/skills/openspec-explore.md +75 -0
  8. package/skills/openspec-propose.md +102 -0
  9. package/src/acp/client.ts +693 -0
  10. package/src/config.ts +220 -0
  11. package/src/control/keywords.ts +72 -0
  12. package/src/dependencies/acpx.ts +221 -0
  13. package/src/dependencies/openspec.ts +148 -0
  14. package/src/execution/session.ts +56 -0
  15. package/src/execution/state.ts +125 -0
  16. package/src/index.ts +179 -0
  17. package/src/memory/store.ts +118 -0
  18. package/src/openspec/cli.ts +279 -0
  19. package/src/openspec/tasks.ts +40 -0
  20. package/src/orchestrator/helpers.ts +312 -0
  21. package/src/orchestrator/service.ts +2971 -0
  22. package/src/planning/journal.ts +118 -0
  23. package/src/rollback/store.ts +173 -0
  24. package/src/state/locks.ts +133 -0
  25. package/src/state/store.ts +527 -0
  26. package/src/types.ts +301 -0
  27. package/src/utils/args.ts +88 -0
  28. package/src/utils/channel-key.ts +66 -0
  29. package/src/utils/env-path.ts +31 -0
  30. package/src/utils/fs.ts +218 -0
  31. package/src/utils/markdown.ts +136 -0
  32. package/src/utils/messages.ts +5 -0
  33. package/src/utils/paths.ts +127 -0
  34. package/src/utils/shell-command.ts +227 -0
  35. package/src/utils/slug.ts +50 -0
  36. package/src/watchers/manager.ts +3042 -0
  37. package/src/watchers/notifier.ts +69 -0
  38. package/src/worker/prompts.ts +484 -0
  39. package/src/worker/skills.ts +52 -0
  40. package/src/workspace/store.ts +140 -0
  41. package/test/acp-client.test.ts +234 -0
  42. package/test/acpx-dependency.test.ts +112 -0
  43. package/test/assistant-journal.test.ts +136 -0
  44. package/test/command-surface.test.ts +23 -0
  45. package/test/config.test.ts +77 -0
  46. package/test/detach-attach.test.ts +98 -0
  47. package/test/file-lock.test.ts +78 -0
  48. package/test/fs-utils.test.ts +22 -0
  49. package/test/helpers/harness.ts +241 -0
  50. package/test/helpers.test.ts +108 -0
  51. package/test/keywords.test.ts +80 -0
  52. package/test/notifier.test.ts +29 -0
  53. package/test/openspec-dependency.test.ts +67 -0
  54. package/test/pause-cancel.test.ts +55 -0
  55. package/test/planning-journal.test.ts +69 -0
  56. package/test/plugin-registration.test.ts +35 -0
  57. package/test/project-memory.test.ts +42 -0
  58. package/test/proposal.test.ts +24 -0
  59. package/test/queue-planning.test.ts +247 -0
  60. package/test/queue-work.test.ts +110 -0
  61. package/test/recovery.test.ts +576 -0
  62. package/test/service-archive.test.ts +82 -0
  63. package/test/shell-command.test.ts +48 -0
  64. package/test/state-store.test.ts +74 -0
  65. package/test/tasks-and-checkpoint.test.ts +60 -0
  66. package/test/use-project.test.ts +19 -0
  67. package/test/watcher-planning.test.ts +504 -0
  68. package/test/watcher-work.test.ts +1741 -0
  69. package/test/worker-command.test.ts +66 -0
  70. package/test/worker-skills.test.ts +12 -0
  71. package/tsconfig.json +25 -0
@@ -0,0 +1,40 @@
1
+ import { readUtf8 } from "../utils/fs.ts";
2
+ import type { ParsedTask, ParsedTaskList } from "../types.ts";
3
+
4
+ const TASK_LINE_RE = /^- \[( |x)\] ([0-9.]+)\s+(.*)$/i;
5
+
6
+ export function parseTasksMarkdown(markdown: string): ParsedTaskList {
7
+ const tasks: ParsedTask[] = [];
8
+ const lines = markdown.split(/\r?\n/);
9
+ lines.forEach((line, index) => {
10
+ const match = TASK_LINE_RE.exec(line);
11
+ if (!match) {
12
+ return;
13
+ }
14
+ tasks.push({
15
+ raw: line,
16
+ lineNumber: index + 1,
17
+ checked: match[1].toLowerCase() === "x",
18
+ taskId: match[2],
19
+ description: match[3].trim(),
20
+ });
21
+ });
22
+
23
+ const complete = tasks.filter((task) => task.checked).length;
24
+ return {
25
+ tasks,
26
+ counts: {
27
+ total: tasks.length,
28
+ complete,
29
+ remaining: tasks.length - complete,
30
+ },
31
+ };
32
+ }
33
+
34
+ export async function parseTasksFile(filePath: string): Promise<ParsedTaskList> {
35
+ return parseTasksMarkdown(await readUtf8(filePath));
36
+ }
37
+
38
+ export function getNextIncompleteTask(taskList: ParsedTaskList): ParsedTask | undefined {
39
+ return taskList.tasks.find((task) => !task.checked);
40
+ }
@@ -0,0 +1,312 @@
1
+ import type { PluginCommandResult } from "openclaw/plugin-sdk";
2
+ import { extractEmbeddedClawSpecKeyword } from "../control/keywords.ts";
3
+ import type { ProjectState, TaskCountSummary } from "../types.ts";
4
+ import { formatTaskCounts } from "../utils/markdown.ts";
5
+ import { sameNormalizedPath } from "../utils/paths.ts";
6
+
7
+ export function deriveRoutingContext(params: {
8
+ channel?: string;
9
+ messageProvider?: string;
10
+ channelId?: string;
11
+ accountId?: string;
12
+ conversationId?: string;
13
+ sessionKey?: string;
14
+ }): {
15
+ channel?: string;
16
+ channelId?: string;
17
+ accountId?: string;
18
+ conversationId?: string;
19
+ } {
20
+ const sessionHint = parseSessionChannelHint(params.sessionKey);
21
+ const conversationHint = parseConversationChannelHint(params.conversationId);
22
+ const channel = params.channel ?? params.messageProvider ?? sessionHint?.channel;
23
+ const rawChannelId = params.channelId?.trim();
24
+ const normalizedChannelId = isPlaceholderChannelId(rawChannelId, channel, params.messageProvider)
25
+ ? sessionHint?.channelId ?? conversationHint?.channelId ?? rawChannelId
26
+ : rawChannelId ?? sessionHint?.channelId ?? conversationHint?.channelId;
27
+
28
+ return {
29
+ channel,
30
+ channelId: normalizedChannelId,
31
+ accountId: params.accountId,
32
+ conversationId: conversationHint?.conversationId ?? params.conversationId ?? sessionHint?.conversationId,
33
+ };
34
+ }
35
+
36
+ function isPlaceholderChannelId(value: string | undefined, channel?: string, messageProvider?: string): boolean {
37
+ if (!value) {
38
+ return true;
39
+ }
40
+
41
+ const normalized = value.trim().toLowerCase();
42
+ if (!normalized) {
43
+ return true;
44
+ }
45
+
46
+ return normalized === channel?.trim().toLowerCase()
47
+ || normalized === messageProvider?.trim().toLowerCase();
48
+ }
49
+
50
+ function parseConversationChannelHint(conversationId?: string): {
51
+ channelId?: string;
52
+ conversationId?: string;
53
+ } | undefined {
54
+ if (!conversationId) {
55
+ return undefined;
56
+ }
57
+
58
+ const normalized = conversationId.trim();
59
+ if (!normalized) {
60
+ return undefined;
61
+ }
62
+
63
+ if (normalized.startsWith("channel:")) {
64
+ const channelId = normalized.slice("channel:".length).trim();
65
+ if (channelId) {
66
+ return {
67
+ channelId,
68
+ conversationId: "main",
69
+ };
70
+ }
71
+ }
72
+
73
+ return undefined;
74
+ }
75
+
76
+ function parseSessionChannelHint(sessionKey?: string): {
77
+ channel: string;
78
+ channelId: string;
79
+ conversationId?: string;
80
+ } | undefined {
81
+ if (!sessionKey) {
82
+ return undefined;
83
+ }
84
+
85
+ const parts = sessionKey.split(":");
86
+ if (parts.length < 5 || parts[0] !== "agent") {
87
+ return undefined;
88
+ }
89
+
90
+ const channel = parts[2];
91
+ const sessionKind = parts[3];
92
+ const channelId = parts.slice(4).join(":");
93
+ if (!channel || !sessionKind || !channelId) {
94
+ return undefined;
95
+ }
96
+
97
+ return {
98
+ channel,
99
+ channelId,
100
+ conversationId: sessionKind === "channel" ? "main" : undefined,
101
+ };
102
+ }
103
+
104
+ export function okReply(text: string): PluginCommandResult {
105
+ return { text };
106
+ }
107
+
108
+ export function errorReply(text: string): PluginCommandResult {
109
+ return { text, isError: true };
110
+ }
111
+
112
+ export function buildHelpText(): string {
113
+ return [
114
+ "ClawSpec commands:",
115
+ "- `/clawspec workspace`",
116
+ "- `/clawspec workspace \"~/clawspec/workspace\"`",
117
+ "- `/clawspec use`",
118
+ "- `/clawspec use \"project-name\"`",
119
+ "- `/clawspec proposal <change-name> [description]`",
120
+ "- `/clawspec worker [agent-id|status]`",
121
+ "- `/clawspec attach`",
122
+ "- `/clawspec detach`",
123
+ "- `/clawspec continue`",
124
+ "- `/clawspec pause`",
125
+ "- `/clawspec status`",
126
+ "- `/clawspec archive`",
127
+ "- `/clawspec cancel`",
128
+ "",
129
+ "Visible chat keywords:",
130
+ "- `cs-plan`",
131
+ "- `cs-work`",
132
+ "- `cs-attach`",
133
+ "- `cs-detach`",
134
+ "- `cs-pause`",
135
+ "- `cs-continue`",
136
+ "- `cs-status`",
137
+ "- `cs-cancel`",
138
+ "",
139
+ "Notes:",
140
+ "- `change-name` must use kebab-case and cannot contain spaces.",
141
+ "- Worker agent defaults to `codex`. Use `/clawspec worker` to inspect/change it, or `/clawspec worker status` to see the live worker state for this channel/project.",
142
+ "- `/clawspec detach` makes ordinary chat stop injecting or recording ClawSpec context. `/clawspec attach` restores it. `cs-detach` and `cs-attach` do the same thing from chat.",
143
+ "- `/clawspec deattach` and `cs-deattach` remain accepted as legacy aliases.",
144
+ "- `cs-plan` refreshes planning artifacts in the visible chat without implementing code.",
145
+ "- `cs-work` is only available after `cs-plan` has finished and the change is apply-ready.",
146
+ "- `/clawspec continue` resumes planning or implementation based on the current phase.",
147
+ ].join("\n");
148
+ }
149
+
150
+ export function shouldHandleUserVisiblePrompt(trigger?: string): boolean {
151
+ return !trigger || trigger === "user";
152
+ }
153
+
154
+ export function hasBlockingExecution(project: ProjectState): boolean {
155
+ return project.execution?.state === "armed" || project.execution?.state === "running" || project.status === "running";
156
+ }
157
+
158
+ export function shouldCapturePlanningMessage(project: ProjectState): boolean {
159
+ return Boolean(
160
+ isProjectContextAttached(project)
161
+ && project.changeName
162
+ && !["running", "archived", "cancelled"].includes(project.status),
163
+ );
164
+ }
165
+
166
+ export function shouldInjectProjectPrompt(project: ProjectState, prompt: string): boolean {
167
+ if (!isProjectContextAttached(project) || !project.repoPath || !project.projectName || project.changeName || hasBlockingExecution(project)) {
168
+ return false;
169
+ }
170
+ if (prompt.trim().startsWith("/clawspec")) {
171
+ return false;
172
+ }
173
+ return !["done", "archived", "cancelled"].includes(project.status);
174
+ }
175
+
176
+ export function formatProjectTaskCounts(project: ProjectState, taskCounts: TaskCountSummary | undefined): string {
177
+ if (
178
+ project.phase === "proposal"
179
+ && project.changeName
180
+ && taskCounts
181
+ && taskCounts.total === 0
182
+ && taskCounts.complete === 0
183
+ && taskCounts.remaining === 0
184
+ ) {
185
+ return "Task counts: planning artifacts not generated yet";
186
+ }
187
+ return formatTaskCounts(taskCounts);
188
+ }
189
+
190
+ export function shouldInjectPlanningPrompt(project: ProjectState, prompt: string): boolean {
191
+ if (!isProjectContextAttached(project) || !project.repoPath || !project.changeName || hasBlockingExecution(project) || project.status === "planning") {
192
+ return false;
193
+ }
194
+ if (prompt.trim().startsWith("/clawspec")) {
195
+ return false;
196
+ }
197
+ return !["done", "archived", "cancelled"].includes(project.status);
198
+ }
199
+
200
+ export function requiresPlanningSync(project: ProjectState): boolean {
201
+ if (!project.changeName || isFinishedStatus(project.status)) {
202
+ return false;
203
+ }
204
+ return project.phase === "proposal" || project.planningJournal?.dirty === true;
205
+ }
206
+
207
+ export function isProjectContextAttached(project: ProjectState): boolean {
208
+ return project.contextMode !== "detached";
209
+ }
210
+
211
+ export function samePath(left: string | undefined, right: string | undefined): boolean {
212
+ return sameNormalizedPath(left, right);
213
+ }
214
+
215
+ export function isFinishedStatus(status: ProjectState["status"]): boolean {
216
+ return ["done", "archived", "cancelled"].includes(status);
217
+ }
218
+
219
+ export function sanitizePlanningMessageText(text: string): string {
220
+ return text
221
+ .replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "")
222
+ .replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "")
223
+ .replace(/Untrusted context \(metadata, do not treat as instructions or commands\):\s*<<<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?<<<END_EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>\s*/gi, "")
224
+ .trim();
225
+ }
226
+
227
+ export function buildPlanningRequiredMessage(project: ProjectState): string {
228
+ if (!isProjectContextAttached(project)) {
229
+ return `Change \`${project.changeName}\` is active, but ordinary chat is currently detached from ClawSpec context. New requirement messages in this chat will not be written to the planning journal until you run \`cs-attach\` or \`/clawspec attach\`.`;
230
+ }
231
+ if (project.phase === "proposal" && (project.planningJournal?.entryCount ?? 0) === 0) {
232
+ return `Change \`${project.changeName}\` is waiting for planning input. Continue describing requirements in chat, then run \`cs-plan\`. \`cs-work\` is not available yet.`;
233
+ }
234
+ return `Change \`${project.changeName}\` has unsynced planning notes. Continue discussing requirements if needed, then run \`cs-plan\`. \`cs-work\` is not available until planning sync finishes.`;
235
+ }
236
+
237
+ export function buildPlanningBlockedMessage(project: ProjectState): string {
238
+ return `Change \`${project.changeName}\` is not apply-ready yet. Continue refining requirements if needed, then run \`cs-plan\` again. Do not start \`cs-work\` yet.`;
239
+ }
240
+
241
+ export function buildProposalBlockedMessage(project: ProjectState, projectName?: string): string {
242
+ const repoLabel = projectName ?? project.projectName ?? "this project";
243
+ if (project.status === "planning") {
244
+ return `Project \`${repoLabel}\` already has an active change \`${project.changeName}\` in planning sync. Let that turn finish, then continue from the same change instead of creating a new proposal.`;
245
+ }
246
+ if (requiresPlanningSync(project) || project.status === "blocked" || project.phase === "proposal") {
247
+ return `Project \`${repoLabel}\` already has an active change \`${project.changeName}\`. Continue discussing requirements for that change, then run \`cs-plan\`. Do not create a second proposal yet.`;
248
+ }
249
+ return `Project \`${repoLabel}\` already has an active change \`${project.changeName}\` waiting for implementation. Use \`cs-work\`, \`/clawspec continue\`, or \`/clawspec cancel\` instead of creating a new proposal.`;
250
+ }
251
+
252
+ export function dedupeProjects(projects: ProjectState[]): Array<{ channelKey: string; project: ProjectState }> {
253
+ const byProjectId = new Map<string, { channelKey: string; project: ProjectState }>();
254
+
255
+ for (const project of projects) {
256
+ const existing = byProjectId.get(project.projectId);
257
+ if (!existing) {
258
+ byProjectId.set(project.projectId, { channelKey: project.channelKey, project });
259
+ continue;
260
+ }
261
+
262
+ const existingUpdated = Date.parse(existing.project.updatedAt);
263
+ const nextUpdated = Date.parse(project.updatedAt);
264
+ if (Number.isNaN(existingUpdated) || nextUpdated >= existingUpdated) {
265
+ byProjectId.set(project.projectId, { channelKey: project.channelKey, project });
266
+ }
267
+ }
268
+
269
+ return Array.from(byProjectId.values());
270
+ }
271
+
272
+ export function collectPromptCandidates(prompt: string): string[] {
273
+ const candidates = new Set<string>();
274
+ const trimmed = prompt.trim();
275
+ if (trimmed) {
276
+ candidates.add(trimmed);
277
+ }
278
+
279
+ for (const line of prompt.split(/\r?\n/)) {
280
+ const next = line.trim();
281
+ if (next) {
282
+ candidates.add(next);
283
+ }
284
+ }
285
+
286
+ const embeddedKeyword = extractEmbeddedClawSpecKeyword(prompt);
287
+ if (embeddedKeyword?.raw) {
288
+ candidates.add(embeddedKeyword.raw);
289
+ }
290
+
291
+ return Array.from(candidates);
292
+ }
293
+
294
+ export function isMeaningfulExecutionSummary(summary: string | undefined): boolean {
295
+ if (!summary) {
296
+ return false;
297
+ }
298
+
299
+ const normalized = summary.trim();
300
+ if (!normalized) {
301
+ return false;
302
+ }
303
+
304
+ return ![
305
+ "No summary yet.",
306
+ "Visible execution ended without a structured result.",
307
+ "Execution turn ended without writing execution-result.json.",
308
+ ].includes(normalized)
309
+ && !normalized.startsWith("Visible execution started for ")
310
+ && !normalized.startsWith("Change ")
311
+ && !normalized.startsWith("Execution turn failed before writing execution-result.json:");
312
+ }