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,69 @@
1
+ import type { OpenClawPluginApi, PluginLogger } from "openclaw/plugin-sdk";
2
+ import { parseChannelKey } from "../utils/channel-key.ts";
3
+
4
+ type ClawSpecNotifierOptions = {
5
+ api: OpenClawPluginApi;
6
+ logger: PluginLogger;
7
+ };
8
+
9
+ export class ClawSpecNotifier {
10
+ readonly api: OpenClawPluginApi;
11
+ readonly logger: PluginLogger;
12
+
13
+ constructor(options: ClawSpecNotifierOptions) {
14
+ this.api = options.api;
15
+ this.logger = options.logger;
16
+ }
17
+
18
+ async send(channelKey: string, text: string): Promise<void> {
19
+ const route = parseChannelKey(channelKey);
20
+ const accountId = route.accountId && route.accountId !== "default" ? route.accountId : undefined;
21
+
22
+ try {
23
+ switch (route.channel) {
24
+ case "discord":
25
+ await this.api.runtime.channel.discord.sendMessageDiscord(`channel:${route.channelId}`, text, {
26
+ cfg: this.api.config,
27
+ accountId,
28
+ silent: true,
29
+ });
30
+ return;
31
+ case "telegram":
32
+ await this.api.runtime.channel.telegram.sendMessageTelegram(route.channelId, text, {
33
+ cfg: this.api.config,
34
+ accountId,
35
+ silent: true,
36
+ messageThreadId: parseOptionalNumber(route.conversationId),
37
+ });
38
+ return;
39
+ case "slack":
40
+ await this.api.runtime.channel.slack.sendMessageSlack(route.channelId, text, {
41
+ cfg: this.api.config,
42
+ accountId,
43
+ threadTs: route.conversationId !== "main" ? route.conversationId : undefined,
44
+ });
45
+ return;
46
+ case "signal":
47
+ await this.api.runtime.channel.signal.sendMessageSignal(route.channelId, text, {
48
+ cfg: this.api.config,
49
+ accountId,
50
+ });
51
+ return;
52
+ default:
53
+ this.logger.info(`[clawspec] watcher update (${route.channel} ${route.channelId}): ${text}`);
54
+ }
55
+ } catch (error) {
56
+ this.logger.warn(
57
+ `[clawspec] failed to send watcher update to ${channelKey}: ${error instanceof Error ? error.message : String(error)}`,
58
+ );
59
+ }
60
+ }
61
+ }
62
+
63
+ function parseOptionalNumber(value: string): number | undefined {
64
+ if (!value || value === "main") {
65
+ return undefined;
66
+ }
67
+ const parsed = Number(value);
68
+ return Number.isFinite(parsed) ? parsed : undefined;
69
+ }
@@ -0,0 +1,484 @@
1
+ import path from "node:path";
2
+ import type {
3
+ ExecutionMode,
4
+ OpenSpecApplyInstructionsResponse,
5
+ OpenSpecInstructionsResponse,
6
+ ProjectState,
7
+ } from "../types.ts";
8
+ import type { RepoStatePaths } from "../utils/paths.ts";
9
+ import { resolveProjectScopedPath } from "../utils/paths.ts";
10
+
11
+ export function buildExecutionSystemContext(repoPath: string, importedSkills?: string): string {
12
+ return [
13
+ "You are the ClawSpec execution worker running inside the user's visible OpenClaw chat.",
14
+ `Repository: ${repoPath}`,
15
+ "Core rules:",
16
+ "- Current ClawSpec state overrides any prior session memory, hook-generated memory, or recalled project notes.",
17
+ "- Ignore any remembered change names, tasks, or feature ideas that do not match the active repository and active change for this turn.",
18
+ "- The active change named in the execution context is the only valid OpenSpec change for this turn.",
19
+ "- Never read from, write to, or reference files under `openspec/changes/<other-change>`.",
20
+ "- Never rely on Memory Search results for unrelated historical changes. If memory mentions another change, treat it as stale and ignore it.",
21
+ "- OpenSpec is the canonical workflow source.",
22
+ "- tasks.md is the only task source of truth.",
23
+ "- Keep changes minimal and scoped to the active change.",
24
+ "- Use the repo-local ClawSpec control and result files exactly as instructed.",
25
+ "- Keep going until the change is done, paused, cancelled, blocked, or a true clarification is required.",
26
+ "- Progress reporting is mandatory in normal chat messages: announce each artifact refresh, each task start, each task completion, and the final status.",
27
+ importedSkills ? "" : "",
28
+ importedSkills ? "Use these imported OpenSpec workflow skills only where they are consistent with the active repository, active change, and current phase. If they conflict, follow the ClawSpec rules above." : "",
29
+ importedSkills ?? "",
30
+ ].join("\n");
31
+ }
32
+
33
+ export function buildPlanningSystemContext(params: {
34
+ repoPath: string;
35
+ importedSkills?: string;
36
+ mode: "discussion" | "sync";
37
+ }): string {
38
+ return [
39
+ "You are the ClawSpec planning assistant running inside the user's visible OpenClaw chat.",
40
+ `Repository: ${params.repoPath}`,
41
+ params.mode === "sync"
42
+ ? "This turn is a deliberate planning sync turn. Refresh OpenSpec artifacts and stop before implementation."
43
+ : "This turn is a planning discussion turn. Explore and refine the change without implementing code.",
44
+ "Core rules:",
45
+ "- Current ClawSpec state overrides any prior session memory, hook-generated memory, or recalled project notes.",
46
+ "- Ignore any remembered change names, requirements, or feature threads that do not match the active repository and active change for this turn.",
47
+ "- The active change named in the planning context is the only valid OpenSpec change for this turn.",
48
+ "- Never create, switch to, inspect, or cite another OpenSpec change directory unless the user explicitly cancels or archives the current change first.",
49
+ "- Never rely on Memory Search results for unrelated historical changes. If memory mentions another change, treat it as stale and ignore it.",
50
+ "- OpenSpec is the canonical workflow source.",
51
+ "- proposal.md, specs, design.md, and tasks.md define planning state.",
52
+ "- Do not implement product code while in planning mode.",
53
+ "- Keep discussion grounded in the active repository and change.",
54
+ params.mode === "discussion"
55
+ ? "- Ordinary discussion mode never starts planning sync by itself. Only an explicit `cs-plan` request may start planning refresh."
56
+ : "- This sync turn is allowed to inspect and update planning artifacts for the active change.",
57
+ params.importedSkills ? "" : "",
58
+ params.importedSkills ? "Use these imported OpenSpec workflow skills only where they are consistent with the active repository, active change, and current phase. If they conflict, follow the ClawSpec rules above." : "",
59
+ params.importedSkills ?? "",
60
+ ].join("\n");
61
+ }
62
+
63
+ export function buildProjectSystemContext(params: {
64
+ repoPath: string;
65
+ }): string {
66
+ return [
67
+ "You are the ClawSpec project assistant running inside the user's visible OpenClaw chat.",
68
+ `Repository: ${params.repoPath}`,
69
+ "Core rules:",
70
+ "- Current ClawSpec project selection overrides any prior session memory, hook-generated memory, or recalled project notes.",
71
+ "- Ignore remembered change names or project ideas unless the user explicitly re-selects or re-proposes them in this chat.",
72
+ "- Treat the selected repository as the default project context for this turn.",
73
+ "- If the user says 'this project', they mean the selected repository shown below.",
74
+ "- There is no active OpenSpec change yet unless the prompt says otherwise.",
75
+ "- Help the user discuss requirements, repo structure, and next steps without inventing a change name.",
76
+ "- If the user is ready to start structured planning, tell them to run `/clawspec proposal <change-name> [description]`.",
77
+ ].join("\n");
78
+ }
79
+
80
+ export function buildExecutionPrependContext(params: {
81
+ project: ProjectState;
82
+ mode: ExecutionMode;
83
+ userPrompt: string;
84
+ repoStatePaths: RepoStatePaths;
85
+ }): string {
86
+ const project = params.project;
87
+ const changeName = project.changeName ?? "";
88
+ const tasksPath = project.repoPath && project.changeName
89
+ ? path.join(project.repoPath, "openspec", "changes", project.changeName, "tasks.md")
90
+ : "unknown";
91
+ const taskCounts = project.taskCounts;
92
+ const progressLabel = taskCounts
93
+ ? `${taskCounts.complete}/${taskCounts.total} complete, ${taskCounts.remaining} remaining`
94
+ : "unknown";
95
+
96
+ const resultTemplate = JSON.stringify({
97
+ version: 1,
98
+ changeName,
99
+ mode: params.mode,
100
+ status: "done",
101
+ timestamp: "ISO-8601 timestamp",
102
+ summary: "Short execution summary",
103
+ progressMade: true,
104
+ completedTask: "optional task id + description",
105
+ currentArtifact: "optional artifact id",
106
+ changedFiles: ["relative/path.ts"],
107
+ notes: ["short note"],
108
+ blocker: "optional blocker",
109
+ taskCounts: { total: 0, complete: 0, remaining: 0 },
110
+ remainingTasks: 0,
111
+ }, null, 2);
112
+
113
+ return [
114
+ "ClawSpec execution mode is armed for this chat.",
115
+ `Change: ${changeName}`,
116
+ `Mode: ${params.mode}`,
117
+ `Workspace: ${project.workspacePath ?? "_unknown_"}`,
118
+ `Repo path: ${project.repoPath ?? "_unknown_"}`,
119
+ `Change directory: ${project.changeDir ?? "_unknown_"}`,
120
+ "",
121
+ "Current user message that triggered this run:",
122
+ fence(params.userPrompt || "(empty)"),
123
+ "",
124
+ "Read these files before making decisions:",
125
+ `- ${params.repoStatePaths.stateFile}`,
126
+ `- ${params.repoStatePaths.executionControlFile}`,
127
+ `- ${params.repoStatePaths.planningJournalFile}`,
128
+ `- ${tasksPath}`,
129
+ project.changeDir ? `- ${displayPath(path.join(project.changeDir, "proposal.md"))}` : "",
130
+ project.changeDir ? `- ${displayPath(path.join(project.changeDir, "design.md"))}` : "",
131
+ project.changeDir ? `- ${displayPath(path.join(project.changeDir, "specs", "**", "*.md"))}` : "",
132
+ "",
133
+ "Required workflow:",
134
+ "1. Before any tool call or file edit, send a kickoff message in this shape: `Execution started for <change>. Phase: <planning-sync|implementation>. Progress: <complete>/<total> complete, <remaining> remaining. Next: <artifact or task>.`",
135
+ ` Current known progress: ${progressLabel}.`,
136
+ "2. Read execution-control.json first. If cancelRequested is true, stop safely, write execution-result.json with status `cancelled`, and do not continue implementation.",
137
+ "3. If pauseRequested is true before starting new work, stop safely, write execution-result.json with status `paused`, and do not continue implementation.",
138
+ "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`.",
139
+ "5. After planning sync, run `openspec instructions apply --change <name> --json`, read the returned context files, and use that instruction as the implementation guide.",
140
+ "6. Execute unchecked tasks from tasks.md sequentially. Each time a task is fully complete, update its checkbox from `- [ ]` to `- [x]` immediately.",
141
+ "7. Between artifacts and tasks, re-check execution-control.json for pauseRequested or cancelRequested.",
142
+ "8. Keep OpenSpec command activity visible by running those commands normally in this chat.",
143
+ "9. Keep the user informed in this chat with explicit progress messages.",
144
+ "10. Before each planning artifact refresh, send a short message naming the artifact you are about to refresh.",
145
+ "11. After each planning artifact refresh, send a short message saying what changed and what artifact comes next.",
146
+ "12. Before each implementation task, send a progress message in this shape: `Working on task <id>: <description>`.",
147
+ "13. Right after each completed task, send a progress message in this shape: `Completed task <id>: <summary>. Files: <file list or none>. Next: <next task or done>.`",
148
+ "14. If you hit a blocker, do not stop silently. Before ending, send a chat message in this shape: `Blocked: <exact reason>. Affected: <artifact or task ids>. Next: <what the user needs to do>.`",
149
+ "15. When the whole run finishes, send one short final summary that lists completed tasks, key changed files, and whether the change is done or blocked.",
150
+ "",
151
+ "Support-file updates required during this run:",
152
+ `- Update ${params.repoStatePaths.latestSummaryFile} with the latest concise status.`,
153
+ `- Append meaningful milestones to ${params.repoStatePaths.progressFile}.`,
154
+ `- Update ${params.repoStatePaths.changedFilesFile} with changed relative paths when you know them.`,
155
+ `- Append important reasoning or decisions to ${params.repoStatePaths.decisionLogFile}.`,
156
+ "",
157
+ `Before you stop for any reason, write ${params.repoStatePaths.executionResultFile} as valid JSON using this shape:`,
158
+ fence(resultTemplate, "json"),
159
+ "",
160
+ "Status rules for execution-result.json:",
161
+ "- `done`: all tasks complete.",
162
+ "- `paused`: pauseRequested was honored at a safe boundary.",
163
+ "- `cancelled`: cancelRequested was honored at a safe boundary.",
164
+ "- `blocked`: you hit a real blocker or missing requirement.",
165
+ "- `running`: only if you are deliberately yielding with work still in progress and no blocker; avoid this unless necessary.",
166
+ ]
167
+ .filter((line) => line !== "")
168
+ .join("\n");
169
+ }
170
+
171
+ export function buildPlanningPrependContext(params: {
172
+ project: ProjectState;
173
+ userPrompt: string;
174
+ repoStatePaths: RepoStatePaths;
175
+ contextPaths: string[];
176
+ scaffoldOnly?: boolean;
177
+ mode: "discussion" | "sync";
178
+ nextActionHint?: "plan" | "work";
179
+ }): string {
180
+ const project = params.project;
181
+
182
+ const workflow = params.mode === "sync"
183
+ ? [
184
+ "Required workflow for this turn:",
185
+ "0. The active change directory shown above is the only OpenSpec change directory you may inspect or modify in this turn.",
186
+ "1. Read planning-journal.jsonl, .openspec.yaml, and any planning artifacts that already exist.",
187
+ "2. 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.",
188
+ "3. If there is no substantive planning change, say so clearly in chat and do not rewrite artifacts unnecessarily.",
189
+ "4. Run `openspec status --change <name> --json` to inspect artifact readiness.",
190
+ "5. If artifacts are missing or stale, use `openspec instructions <artifact> --change <name> --json` and update `proposal`, `specs`, `design`, and `tasks` in dependency order.",
191
+ "6. Keep OpenSpec command activity visible by running those commands normally in this chat.",
192
+ "7. Before updating each artifact, post a short chat update naming the artifact you are about to refresh.",
193
+ "8. After updating each artifact, post a short chat update describing what changed and what artifact comes next.",
194
+ "9. Stop after planning artifacts are refreshed and apply-ready. Do not implement code in this turn.",
195
+ "10. End with a concise summary of what changed, what remains open, and tell the user to say `cs-work` when they want implementation to start.",
196
+ "11. Never scan sibling directories under `openspec/changes`, never switch to another change, and never restore or rewrite unrelated files.",
197
+ ]
198
+ : [
199
+ "Discussion rules for this turn:",
200
+ "1. Treat this chat as active OpenSpec planning for the current change.",
201
+ "2. Use the imported skills to explore scope, requirements, and design tradeoffs.",
202
+ "3. Do not implement code.",
203
+ "4. Do not tell the user to run `/clawspec use` again. This repo and change are already active in this chat.",
204
+ "5. Do not tell the user to start a second proposal for the same repo while this change is active.",
205
+ "6. If the user is actually describing a separate feature that should be a new change, explain that the current active change must be cancelled or archived first. Do not silently switch changes.",
206
+ "7. Missing `proposal.md`, `design.md`, `specs`, or `tasks.md` is normal before `cs-plan` runs. Do not treat missing planning artifacts as an error during discussion.",
207
+ "8. Only update planning artifacts if the user explicitly asks or if they send `cs-plan` in a later turn.",
208
+ "9. Do not edit any files, do not run git checkout/reset/restore, and do not create or modify OpenSpec artifacts during ordinary discussion turns.",
209
+ "10. Do not run `openspec status`, `openspec instructions`, `openspec apply`, or any other planning command during ordinary discussion turns.",
210
+ "11. Do not scan sibling directories under `openspec/changes`, do not inspect proposal/design/spec/tasks unless the user explicitly asks to review those files, and do not inspect any change other than the active one shown above.",
211
+ "12. Do not say planning has started, queued, refreshed, synced, or completed unless the user explicitly sent `cs-plan` in this same turn.",
212
+ "13. If the current user message adds, removes, or changes requirements, treat that as pending planning input, discuss it briefly, and explicitly tell the user that `cs-plan` is the next step before any further implementation.",
213
+ "14. When the current user message changes requirements, do not say the next step is `cs-work`, `continue implementation`, or anything equivalent in that same reply.",
214
+ "15. If you consult Memory Search at all, only use the active change name; never search for unrelated historical change names.",
215
+ params.nextActionHint === "plan"
216
+ ? "16. Remind the user to continue describing requirements if needed, then run `cs-plan`. Do not tell them to start `cs-work` yet."
217
+ : "16. Only mention `cs-work` as the next step when the current user message is not introducing new requirements. Any new requirement changes must point back to `cs-plan` first.",
218
+ ];
219
+
220
+ return [
221
+ params.mode === "sync"
222
+ ? "ClawSpec planning sync is active for this turn."
223
+ : "ClawSpec planning discussion mode is active for this turn.",
224
+ `Change: ${project.changeName ?? ""}`,
225
+ `Workspace: ${project.workspacePath ?? "_unknown_"}`,
226
+ `Repo path: ${project.repoPath ?? "_unknown_"}`,
227
+ `Change directory: ${project.changeDir ?? "_unknown_"}`,
228
+ "",
229
+ "Current user message:",
230
+ fence(params.userPrompt || "(empty)"),
231
+ "",
232
+ "Read these files before responding:",
233
+ ...params.contextPaths.map((contextPath) => `- ${contextPath}`),
234
+ params.scaffoldOnly ? "" : "",
235
+ params.scaffoldOnly ? "Only the change scaffold exists right now. That is expected before planning sync generates the first artifacts." : "",
236
+ "",
237
+ ...workflow,
238
+ ].join("\n");
239
+ }
240
+
241
+ export function buildProjectPrependContext(params: {
242
+ project: ProjectState;
243
+ userPrompt: string;
244
+ }): string {
245
+ const project = params.project;
246
+ return [
247
+ "ClawSpec project context is active for this turn.",
248
+ `Project: ${project.projectName ?? "_unknown_"}`,
249
+ `Workspace: ${project.workspacePath ?? "_unknown_"}`,
250
+ `Repo path: ${project.repoPath ?? "_unknown_"}`,
251
+ "",
252
+ "Current user message:",
253
+ fence(params.userPrompt || "(empty)"),
254
+ "",
255
+ "Discussion rules for this turn:",
256
+ "1. Treat this repository as the user's current project context.",
257
+ "2. If the user refers to 'this project', map it to the repo path above.",
258
+ "3. Do not assume there is an active change yet.",
259
+ "4. If the user starts describing work they want to build, help refine it and tell them to create a new change with `/clawspec proposal <change-name> [description]` when they are ready.",
260
+ ].join("\n");
261
+ }
262
+
263
+ export function buildPluginReplySystemContext(): string {
264
+ return [
265
+ "The ClawSpec plugin already handled the control request before this turn.",
266
+ "Use the prepared result below to answer the user.",
267
+ "Do not run extra project workflow commands or edit files unless the prepared result explicitly asks for follow-up discussion.",
268
+ ].join("\n");
269
+ }
270
+
271
+ export function buildPluginReplyPrependContext(params: {
272
+ userPrompt: string;
273
+ resultText: string;
274
+ followUp?: string;
275
+ }): string {
276
+ return [
277
+ "ClawSpec plugin result for this turn:",
278
+ fence(params.resultText, "markdown"),
279
+ params.followUp ? "" : "",
280
+ params.followUp ? `Follow-up guidance: ${params.followUp}` : "",
281
+ "",
282
+ "Current user message:",
283
+ fence(params.userPrompt || "(empty)"),
284
+ "",
285
+ "Respond to the user with the result and the follow-up guidance. Keep the wording direct.",
286
+ ]
287
+ .filter((line) => line !== "")
288
+ .join("\n");
289
+ }
290
+
291
+ export function buildAcpPlanningTurnPrompt(params: {
292
+ project: ProjectState;
293
+ repoStatePaths: RepoStatePaths;
294
+ instructions: OpenSpecInstructionsResponse;
295
+ importedSkills?: string;
296
+ }): string {
297
+ const dependencyPaths = params.instructions.dependencies
298
+ .filter((dependency) => dependency.done)
299
+ .map((dependency) => resolveProjectScopedPath(params.project, dependency.path));
300
+ const outputPath = resolveProjectScopedPath(params.project, params.instructions.outputPath);
301
+
302
+ return [
303
+ "You are the ClawSpec background planning worker.",
304
+ "Do not post chat messages. Communicate only through files.",
305
+ `Repository: ${params.project.repoPath ?? "_unknown_"}`,
306
+ `Change: ${params.project.changeName ?? "_unknown_"}`,
307
+ `Artifact: ${params.instructions.artifactId}`,
308
+ `Output path: ${outputPath}`,
309
+ importedSkillBlock(params.importedSkills),
310
+ "",
311
+ "Required behavior:",
312
+ "- Only work on the active change shown above.",
313
+ "- Never inspect or modify sibling directories under `openspec/changes`.",
314
+ "- Read the dependency files first if they exist.",
315
+ "- Follow the supplied OpenSpec instruction and template exactly enough to produce a valid artifact.",
316
+ "- Do not implement product code in this turn.",
317
+ "- Do not update any artifact other than the requested output path.",
318
+ `- Before stopping, write ${params.repoStatePaths.executionResultFile} as valid JSON.`,
319
+ "",
320
+ "Files to read first:",
321
+ `- ${params.repoStatePaths.stateFile}`,
322
+ `- ${params.repoStatePaths.planningJournalFile}`,
323
+ `- ${pathOrUnknown(params.project.changeDir, ".openspec.yaml")}`,
324
+ ...dependencyPaths.map((dependencyPath) => `- ${dependencyPath}`),
325
+ "",
326
+ "OpenSpec artifact instruction:",
327
+ fence(params.instructions.instruction, "markdown"),
328
+ "",
329
+ "OpenSpec artifact template:",
330
+ fence(params.instructions.template, "markdown"),
331
+ "",
332
+ "Execution result JSON template:",
333
+ fence(JSON.stringify({
334
+ version: 1,
335
+ changeName: params.project.changeName ?? "",
336
+ mode: "apply",
337
+ status: "running",
338
+ timestamp: "ISO-8601 timestamp",
339
+ summary: `Updated ${params.instructions.artifactId}.`,
340
+ progressMade: true,
341
+ currentArtifact: params.instructions.artifactId,
342
+ changedFiles: [relativeChangeFile(params.project, outputPath)],
343
+ notes: ["Short note about what changed"],
344
+ taskCounts: params.project.taskCounts ?? { total: 0, complete: 0, remaining: 0 },
345
+ }, null, 2), "json"),
346
+ "",
347
+ "If you are blocked, write `status: \"blocked\"` and set `blocker` plus a concise summary.",
348
+ "If you finish successfully, keep the status as `running` so the watcher can continue with the next artifact.",
349
+ ]
350
+ .filter((line) => line !== "")
351
+ .join("\n");
352
+ }
353
+
354
+ export function buildAcpImplementationTurnPrompt(params: {
355
+ project: ProjectState;
356
+ repoStatePaths: RepoStatePaths;
357
+ apply: OpenSpecApplyInstructionsResponse;
358
+ task: { id: string; description: string };
359
+ tasks: Array<{ id: string; description: string }>;
360
+ mode: ExecutionMode;
361
+ importedSkills?: string;
362
+ }): string {
363
+ const contextPaths = Object.values(params.apply.contextFiles).map((contextPath) =>
364
+ resolveProjectScopedPath(params.project, contextPath));
365
+ const tasksPath = params.project.repoPath && params.project.changeName
366
+ ? path.join(params.project.repoPath, "openspec", "changes", params.project.changeName, "tasks.md")
367
+ : "unknown";
368
+ const contextLabels = contextPaths.map((contextPath) => displayPath(contextPath));
369
+ const firstContextLabel = contextLabels[0] ?? displayPath(tasksPath);
370
+ const afterContextLabel = contextLabels[1] ?? `start task ${params.task.id}`;
371
+
372
+ const taskList = params.tasks.map((task) => `- ${task.id} ${task.description}`).join("\n");
373
+
374
+ return [
375
+ "You are the ClawSpec background implementation worker.",
376
+ "Do not post chat messages. Communicate only through files.",
377
+ `Repository: ${params.project.repoPath ?? "_unknown_"}`,
378
+ `Change: ${params.project.changeName ?? "_unknown_"}`,
379
+ `Mode: ${params.mode}`,
380
+ importedSkillBlock(params.importedSkills),
381
+ "",
382
+ "Tasks to implement (in order):",
383
+ taskList,
384
+ "",
385
+ "Required behavior:",
386
+ "- Implement ALL tasks listed above, one by one, in order.",
387
+ "- Read the context files first.",
388
+ "- Keep changes minimal and scoped to the active change.",
389
+ "- Do not inspect or modify sibling `openspec/changes/<other-change>` directories.",
390
+ `- Update ${tasksPath} by switching each task from \`- [ ]\` to \`- [x]\` as you complete it.`,
391
+ `- Append short progress events to ${params.repoStatePaths.workerProgressFile} as valid JSON Lines.`,
392
+ "- Progress events must be human-readable, one line each, and must match the actual work completed.",
393
+ `- Every progress event must include \`current\` (current task number) and \`total\` (total task count for this run).`,
394
+ `- Do not stay silent until \`task_start\`. Emit context-loading progress first.`,
395
+ `- Before reading the context bundle, append one \`status\` event like: \`Preparing ${params.task.id}: loading context. Next: read ${firstContextLabel}.\``,
396
+ "- After reading each context file, append one `status` event naming the file you just loaded and what comes next.",
397
+ `- After the last context file is loaded, append one \`status\` event like: \`Context ready for ${params.task.id}. Next: start implementation. This can take a little time, so please wait.\``,
398
+ `- Before each task starts, append a \`task_start\` event with a message like: \`Start ${params.task.id}: <short description>. Next: <next step>.\``,
399
+ `- Right after each task is complete, append a \`task_done\` event with a message like: \`Done ${params.task.id}: <short summary>. Changed <n> files: <preview or none>. Next: <next step or done>.\``,
400
+ "- If you hit a blocker, append one `blocked` event before writing the final execution result.",
401
+ `- After completing ALL tasks (or if blocked), write ${params.repoStatePaths.executionResultFile} as valid JSON.`,
402
+ "- If a task cannot be completed safely, stop and write `status: \"blocked\"` with a concise blocker message.",
403
+ "",
404
+ "OpenSpec apply instruction:",
405
+ fence(params.apply.instruction, "markdown"),
406
+ "",
407
+ "Context files to read first:",
408
+ ...contextPaths.map((contextPath) => `- ${contextPath}`),
409
+ "",
410
+ "Worker progress JSONL event template:",
411
+ fence(JSON.stringify({
412
+ version: 1,
413
+ timestamp: "ISO-8601 timestamp",
414
+ kind: "status",
415
+ current: 1,
416
+ total: params.tasks.length,
417
+ taskId: params.task.id,
418
+ message: `Preparing ${params.task.id}: loading context. Next: read ${firstContextLabel}.`,
419
+ }, null, 2), "json"),
420
+ "",
421
+ "Worker progress JSONL task-start example:",
422
+ fence(JSON.stringify({
423
+ version: 1,
424
+ timestamp: "ISO-8601 timestamp",
425
+ kind: "task_start",
426
+ current: 1,
427
+ total: params.tasks.length,
428
+ taskId: params.task.id,
429
+ message: `Start ${params.task.id}: ${params.task.description}. Next: ${params.tasks[1]?.id ?? afterContextLabel}.`,
430
+ }, null, 2), "json"),
431
+ "",
432
+ "Execution result JSON template:",
433
+ fence(JSON.stringify({
434
+ version: 1,
435
+ changeName: params.project.changeName ?? "",
436
+ mode: params.mode,
437
+ status: "done",
438
+ timestamp: "ISO-8601 timestamp",
439
+ summary: `Completed ${params.tasks.length} tasks.`,
440
+ progressMade: true,
441
+ completedTask: `${params.tasks[params.tasks.length - 1]?.id ?? ""} ${params.tasks[params.tasks.length - 1]?.description ?? ""}`,
442
+ changedFiles: ["relative/path.ts"],
443
+ notes: ["Short note about what was done"],
444
+ taskCounts: { total: params.apply.progress.total, complete: params.apply.progress.total, remaining: 0 },
445
+ remainingTasks: 0,
446
+ }, null, 2), "json"),
447
+ "",
448
+ "If all tasks are complete, set `status: \"done\"`.",
449
+ "If you completed some but got blocked on a later task, set `status: \"blocked\"` and list only what was accomplished.",
450
+ "If more tasks remain after those listed above, set `status: \"running\"`.",
451
+ "Never set `paused` or `cancelled` yourself; the watcher handles those states.",
452
+ ]
453
+ .filter((line) => line !== "")
454
+ .join("\n");
455
+ }
456
+
457
+ function fence(text: string, language = "text"): string {
458
+ return `\`\`\`${language}\n${text}\n\`\`\``;
459
+ }
460
+
461
+ function importedSkillBlock(importedSkills?: string): string {
462
+ if (!importedSkills) {
463
+ return "";
464
+ }
465
+ return [
466
+ "Imported OpenSpec workflow skills:",
467
+ importedSkills,
468
+ ].join("\n");
469
+ }
470
+
471
+ function pathOrUnknown(rootPath: string | undefined, leafName: string): string {
472
+ return rootPath ? path.join(rootPath, leafName) : path.join("_unknown_", leafName);
473
+ }
474
+
475
+ function relativeChangeFile(project: ProjectState, targetPath: string): string {
476
+ if (!project.repoPath) {
477
+ return targetPath;
478
+ }
479
+ return path.relative(project.repoPath, resolveProjectScopedPath(project, targetPath)).split(path.sep).join("/");
480
+ }
481
+
482
+ function displayPath(targetPath: string): string {
483
+ return targetPath.split(path.sep).join("/");
484
+ }
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { readUtf8 } from "../utils/fs.ts";
4
+
5
+ export type ClawSpecSkillKey = "apply" | "explore" | "propose";
6
+
7
+ const skillCache = new Map<ClawSpecSkillKey, string>();
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+ const skillPaths: Record<ClawSpecSkillKey, string> = {
11
+ apply: path.join(repoRoot, "skills", "openspec-apply-change.md"),
12
+ explore: path.join(repoRoot, "skills", "openspec-explore.md"),
13
+ propose: path.join(repoRoot, "skills", "openspec-propose.md"),
14
+ };
15
+
16
+ export async function loadClawSpecSkillBundle(keys: ClawSpecSkillKey[]): Promise<string> {
17
+ const uniqueKeys = Array.from(new Set(keys));
18
+ const sections: string[] = [];
19
+
20
+ for (const key of uniqueKeys) {
21
+ const body = await loadClawSpecSkill(key);
22
+ sections.push([
23
+ `## Imported Skill: ${skillName(key)}`,
24
+ "",
25
+ body.trim(),
26
+ ].join("\n"));
27
+ }
28
+
29
+ return sections.join("\n\n");
30
+ }
31
+
32
+ async function loadClawSpecSkill(key: ClawSpecSkillKey): Promise<string> {
33
+ const cached = skillCache.get(key);
34
+ if (cached) {
35
+ return cached;
36
+ }
37
+
38
+ const body = await readUtf8(skillPaths[key]);
39
+ skillCache.set(key, body);
40
+ return body;
41
+ }
42
+
43
+ function skillName(key: ClawSpecSkillKey): string {
44
+ switch (key) {
45
+ case "apply":
46
+ return "openspec-apply-change";
47
+ case "explore":
48
+ return "openspec-explore";
49
+ case "propose":
50
+ return "openspec-propose";
51
+ }
52
+ }