clawspec 1.0.5 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawspec",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
6
6
  "keywords": [
@@ -23,6 +23,7 @@ import type {
23
23
  ExecutionResult,
24
24
  OpenSpecApplyInstructionsResponse,
25
25
  OpenSpecCommandResult,
26
+ OpenSpecInstructionsResponse,
26
27
  OpenSpecStatusResponse,
27
28
  ProjectState,
28
29
  TaskCountSummary,
@@ -566,6 +567,7 @@ export class ClawSpecService {
566
567
  private async buildPlanningSyncInjection(
567
568
  project: ProjectState,
568
569
  userPrompt: string,
570
+ instructionResults: Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>>,
569
571
  ): Promise<{ prependContext?: string; prependSystemContext?: string }> {
570
572
  const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
571
573
  await this.ensureProjectSupportFiles(project);
@@ -584,13 +586,20 @@ export class ClawSpecService {
584
586
  contextPaths: planningContext.paths,
585
587
  scaffoldOnly: planningContext.scaffoldOnly,
586
588
  mode: "sync",
589
+ prefetchedInstructions: instructionResults.map((result) => result.parsed!).filter(Boolean),
590
+ prefetchedInstructionCommands: instructionResults.map((result) => result.command).filter(Boolean),
587
591
  }),
588
592
  };
589
593
  }
590
594
 
591
595
  private async preparePlanningSync(channelKey: string): Promise<
592
596
  | { result: PluginCommandResult }
593
- | { project: ProjectState; outputs: OpenSpecCommandResult[]; repoStatePaths: RepoStatePaths }
597
+ | {
598
+ project: ProjectState;
599
+ outputs: OpenSpecCommandResult[];
600
+ repoStatePaths: RepoStatePaths;
601
+ instructionResults: Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>>;
602
+ }
594
603
  > {
595
604
  const project = await this.requireActiveProject(channelKey);
596
605
  if (!project.repoPath || !project.projectName || !project.changeName) {
@@ -661,6 +670,21 @@ export class ClawSpecService {
661
670
  }
662
671
  const statusResult = await this.openSpec.status(project.repoPath, project.changeName);
663
672
  outputs.push(statusResult);
673
+ const instructionResults = await this.refreshPlanningInstructionFiles(project, repoStatePaths);
674
+ outputs.push(...instructionResults);
675
+ await this.writeLatestSummary(
676
+ repoStatePaths,
677
+ `Planning instructions refreshed for ${project.changeName} via OpenSpec CLI.`,
678
+ );
679
+ await removeIfExists(repoStatePaths.executionControlFile);
680
+ await removeIfExists(repoStatePaths.executionResultFile);
681
+ await removeIfExists(repoStatePaths.workerProgressFile);
682
+ return {
683
+ project,
684
+ outputs,
685
+ repoStatePaths,
686
+ instructionResults,
687
+ };
664
688
  } catch (error) {
665
689
  if (error instanceof OpenSpecCommandError) {
666
690
  return {
@@ -677,15 +701,6 @@ export class ClawSpecService {
677
701
  }
678
702
  throw error;
679
703
  }
680
-
681
- await removeIfExists(repoStatePaths.executionControlFile);
682
- await removeIfExists(repoStatePaths.executionResultFile);
683
- await removeIfExists(repoStatePaths.workerProgressFile);
684
- return {
685
- project,
686
- outputs,
687
- repoStatePaths,
688
- };
689
704
  }
690
705
 
691
706
  private async startVisiblePlanningSync(
@@ -717,7 +732,34 @@ export class ClawSpecService {
717
732
  lastExecutionAt: startedAt,
718
733
  }));
719
734
 
720
- return await this.buildPlanningSyncInjection(runningProject, userPrompt);
735
+ return await this.buildPlanningSyncInjection(runningProject, userPrompt, prepared.instructionResults);
736
+ }
737
+
738
+ private async refreshPlanningInstructionFiles(
739
+ project: ProjectState,
740
+ repoStatePaths: RepoStatePaths,
741
+ ): Promise<Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>>> {
742
+ if (!project.repoPath || !project.changeName) {
743
+ return [];
744
+ }
745
+
746
+ const artifactIds = ["proposal", "specs", "design", "tasks"] as const;
747
+ await ensureDir(repoStatePaths.planningInstructionsRoot);
748
+ const results: Array<OpenSpecCommandResult<OpenSpecInstructionsResponse>> = [];
749
+
750
+ for (const artifactId of artifactIds) {
751
+ const result = await this.openSpec.instructionsArtifact(project.repoPath, artifactId, project.changeName);
752
+ results.push(result);
753
+ await writeJsonFile(path.join(repoStatePaths.planningInstructionsRoot, `${artifactId}.json`), {
754
+ generatedAt: new Date().toISOString(),
755
+ command: result.command,
756
+ cwd: result.cwd,
757
+ durationMs: result.durationMs,
758
+ instruction: result.parsed,
759
+ });
760
+ }
761
+
762
+ return results;
721
763
  }
722
764
 
723
765
  private async collectPlanningContextPaths(
@@ -728,6 +770,17 @@ export class ClawSpecService {
728
770
  repoStatePaths.stateFile,
729
771
  repoStatePaths.planningJournalFile,
730
772
  ];
773
+ const instructionFiles = [
774
+ path.join(repoStatePaths.planningInstructionsRoot, "proposal.json"),
775
+ path.join(repoStatePaths.planningInstructionsRoot, "specs.json"),
776
+ path.join(repoStatePaths.planningInstructionsRoot, "design.json"),
777
+ path.join(repoStatePaths.planningInstructionsRoot, "tasks.json"),
778
+ ];
779
+ for (const instructionFile of instructionFiles) {
780
+ if (await pathExists(instructionFile)) {
781
+ paths.push(instructionFile);
782
+ }
783
+ }
731
784
 
732
785
  if (!project.changeDir) {
733
786
  return {
@@ -14,6 +14,7 @@ export type RepoStatePaths = {
14
14
  latestSummaryFile: string;
15
15
  planningJournalFile: string;
16
16
  planningJournalSnapshotFile: string;
17
+ planningInstructionsRoot: string;
17
18
  rollbackManifestFile: string;
18
19
  snapshotsRoot: string;
19
20
  archivesRoot: string;
@@ -75,6 +76,7 @@ export function getRepoStatePaths(repoPath: string, archiveDirName: string): Rep
75
76
  latestSummaryFile: path.join(root, "latest-summary.md"),
76
77
  planningJournalFile: path.join(root, "planning-journal.jsonl"),
77
78
  planningJournalSnapshotFile: path.join(root, "planning-journal.snapshot.json"),
79
+ planningInstructionsRoot: path.join(root, "planning-instructions"),
78
80
  rollbackManifestFile: path.join(root, "rollback-manifest.json"),
79
81
  snapshotsRoot: path.join(root, "snapshots"),
80
82
  archivesRoot: path.join(root, archiveDirName),
@@ -139,6 +139,7 @@ export function buildExecutionPrependContext(params: {
139
139
  "4. If planning-journal state is dirty or planning artifacts are missing, sync `proposal`, `specs`, `design`, and `tasks` in order using `openspec instructions <artifact> --change <name> --json`.",
140
140
  "5. After planning sync, run `openspec instructions apply --change <name> --json`, read the returned context files, and use that instruction as the implementation guide.",
141
141
  "6. Execute unchecked tasks from tasks.md sequentially. Each time a task is fully complete, update its checkbox from `- [ ]` to `- [x]` immediately.",
142
+ "6.1 For code-change tasks, add or update automated tests before marking the task done, and report the test command/result in progress updates.",
142
143
  "7. Between artifacts and tasks, re-check execution-control.json for pauseRequested or cancelRequested.",
143
144
  "8. Keep OpenSpec command activity visible by running those commands normally in this chat.",
144
145
  "9. Keep the user informed in this chat with explicit progress messages.",
@@ -177,6 +178,8 @@ export function buildPlanningPrependContext(params: {
177
178
  scaffoldOnly?: boolean;
178
179
  mode: "discussion" | "sync";
179
180
  nextActionHint?: "plan" | "work";
181
+ prefetchedInstructions?: OpenSpecInstructionsResponse[];
182
+ prefetchedInstructionCommands?: string[];
180
183
  }): string {
181
184
  const project = params.project;
182
185
 
@@ -185,16 +188,18 @@ export function buildPlanningPrependContext(params: {
185
188
  "Required workflow for this turn:",
186
189
  "0. The active change directory shown above is the only OpenSpec change directory you may inspect or modify in this turn.",
187
190
  "1. Read planning-journal.jsonl, .openspec.yaml, and any planning artifacts that already exist.",
188
- "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.",
189
- "3. If there is no substantive planning change, say so clearly in chat and do not rewrite artifacts unnecessarily.",
190
- "4. Run `openspec status --change <name> --json` to inspect artifact readiness.",
191
- "5. If artifacts are missing or stale, use `openspec instructions <artifact> --change <name> --json` and update `proposal`, `specs`, `design`, and `tasks` in dependency order.",
192
- "6. Keep OpenSpec command activity visible by running those commands normally in this chat.",
191
+ "2. Treat the prefetched OpenSpec instruction files in this context as the authoritative source for artifact structure, output paths, and writing guidance.",
192
+ "3. Use the current visible chat context plus the planning journal to decide whether there are substantive new requirements, constraints, or design changes since the last planning sync.",
193
+ "4. If there is no substantive planning change, say so clearly in chat and do not rewrite artifacts unnecessarily.",
194
+ "5. If artifacts are missing or stale, update `proposal`, `specs`, `design`, and `tasks` in dependency order using those prefetched OpenSpec instruction files.",
195
+ "6. Do not generate or rewrite planning artifacts from ad-hoc structure guesses; follow OpenSpec instruction/template constraints only.",
193
196
  "7. Before updating each artifact, post a short chat update naming the artifact you are about to refresh.",
194
197
  "8. After updating each artifact, post a short chat update describing what changed and what artifact comes next.",
195
- "9. Stop after planning artifacts are refreshed and apply-ready. Do not implement code in this turn.",
196
- "10. End with a concise summary and a mandatory final line exactly in this shape: `Next: run `cs-work` to start implementation.`",
197
- "11. Never scan sibling directories under `openspec/changes`, never switch to another change, and never restore or rewrite unrelated files.",
198
+ "9. For any implementation-oriented task item, ensure tasks.md contains an explicit testing task (new tests or updated tests) and a validation command.",
199
+ "10. Stop after planning artifacts are refreshed and apply-ready. Do not implement code in this turn.",
200
+ "11. End with a concise summary and a mandatory final line exactly in this shape: `Next: run `cs-work` to start implementation.`",
201
+ "12. Never scan sibling directories under `openspec/changes`, never switch to another change, and never restore or rewrite unrelated files.",
202
+ "13. Do not claim that OpenSpec instructions were skipped in this sync turn. The plugin already executed those commands before this turn began.",
198
203
  ]
199
204
  : [
200
205
  "Discussion rules for this turn:",
@@ -232,6 +237,23 @@ export function buildPlanningPrependContext(params: {
232
237
  "",
233
238
  "Read these files before responding:",
234
239
  ...params.contextPaths.map((contextPath) => `- ${contextPath}`),
240
+ params.mode === "sync" ? "" : "",
241
+ params.mode === "sync" ? "Prefetched OpenSpec instructions for this turn:" : "",
242
+ ...(params.mode === "sync"
243
+ ? (params.prefetchedInstructions ?? [])
244
+ .map((instruction) =>
245
+ `- ${instruction.artifactId}: ${displayPath(resolveProjectScopedPath(project, instruction.outputPath))}`,
246
+ )
247
+ : []),
248
+ ...(params.mode === "sync"
249
+ ? (params.prefetchedInstructions ?? []).flatMap((instruction) => [
250
+ "",
251
+ formatPrefetchedInstructionBlock(project, instruction),
252
+ ])
253
+ : []),
254
+ params.mode === "sync" ? "" : "",
255
+ params.mode === "sync" ? "OpenSpec commands already executed by the plugin before this turn:" : "",
256
+ ...(params.mode === "sync" ? (params.prefetchedInstructionCommands ?? []).map((command) => `- ${command}`) : []),
235
257
  params.scaffoldOnly ? "" : "",
236
258
  params.scaffoldOnly ? "Only the change scaffold exists right now. That is expected before planning sync generates the first artifacts." : "",
237
259
  "",
@@ -492,3 +514,17 @@ function relativeChangeFile(project: ProjectState, targetPath: string): string {
492
514
  function displayPath(targetPath: string): string {
493
515
  return targetPath.split(path.sep).join("/");
494
516
  }
517
+
518
+ function formatPrefetchedInstructionBlock(
519
+ project: ProjectState,
520
+ instruction: OpenSpecInstructionsResponse,
521
+ ): string {
522
+ const outputPath = displayPath(resolveProjectScopedPath(project, instruction.outputPath));
523
+ return [
524
+ `Artifact ${instruction.artifactId} (output: ${outputPath})`,
525
+ "Instruction:",
526
+ fence(instruction.instruction || "(empty)", "markdown"),
527
+ "Template:",
528
+ fence(instruction.template || "(empty)", "markdown"),
529
+ ].join("\n");
530
+ }
@@ -133,6 +133,27 @@ export async function createServiceHarness(prefix: string): Promise<{
133
133
  ],
134
134
  },
135
135
  }),
136
+ instructionsArtifact: async (cwd: string, artifactId: string, changeName: string) => ({
137
+ command: `openspec instructions ${artifactId} --change ${changeName} --json`,
138
+ cwd,
139
+ stdout: "{}",
140
+ stderr: "",
141
+ durationMs: 1,
142
+ parsed: {
143
+ changeName,
144
+ artifactId,
145
+ schemaName: "spec-driven",
146
+ changeDir,
147
+ outputPath: artifactId === "specs"
148
+ ? path.join(changeDir, "specs", "demo-spec", "spec.md")
149
+ : path.join(changeDir, `${artifactId}.md`),
150
+ description: `Refresh ${artifactId}`,
151
+ instruction: `Use ${artifactId} template`,
152
+ template: `# ${artifactId}`,
153
+ dependencies: [],
154
+ unlocks: [],
155
+ },
156
+ }),
136
157
  instructionsApply: async (cwd: string, changeName: string) => ({
137
158
  command: `openspec instructions apply --change ${changeName} --json`,
138
159
  cwd,
@@ -133,7 +133,7 @@ test("apply reports no new planning notes when the chat context is detached", as
133
133
 
134
134
  test("cs-plan runs visible planning sync and writes a fresh snapshot", async () => {
135
135
  const harness = await createServiceHarness("clawspec-visible-plan-");
136
- const { service, stateStore, repoPath } = harness;
136
+ const { service, stateStore, repoPath, openSpec } = harness;
137
137
  const channelKey = "discord:visible-plan:default:main";
138
138
  const promptContext = {
139
139
  trigger: "user",
@@ -149,6 +149,13 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
149
149
  await service.proposalProject(channelKey, "demo-change Demo change");
150
150
  await service.recordPlanningMessageFromContext(promptContext, "add another API endpoint");
151
151
 
152
+ const instructionCalls: string[] = [];
153
+ const originalInstructionsArtifact = openSpec.instructionsArtifact;
154
+ openSpec.instructionsArtifact = async (...args: unknown[]) => {
155
+ instructionCalls.push(String(args[1]));
156
+ return await originalInstructionsArtifact(...args);
157
+ };
158
+
152
159
  const injected = await service.handleBeforePromptBuild(
153
160
  { prompt: "cs-plan", messages: [] },
154
161
  promptContext,
@@ -156,6 +163,11 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
156
163
  const runningProject = await stateStore.getActiveProject(channelKey);
157
164
 
158
165
  assert.match(injected?.prependContext ?? "", /ClawSpec planning sync is active for this turn/);
166
+ assert.match(injected?.prependContext ?? "", /Prefetched OpenSpec instructions for this turn/);
167
+ assert.match(injected?.prependContext ?? "", /OpenSpec commands already executed by the plugin before this turn/);
168
+ assert.match(injected?.prependContext ?? "", /openspec instructions proposal --change demo-change --json/);
169
+ assert.match(injected?.prependContext ?? "", /planning-instructions[\\/]+proposal\.json/);
170
+ assert.deepEqual(instructionCalls, ["proposal", "specs", "design", "tasks"]);
159
171
  assert.match(injected?.prependContext ?? "", /mandatory final line exactly in this shape/i);
160
172
  assert.equal(runningProject?.status, "planning");
161
173
  assert.equal(runningProject?.phase, "planning_sync");