clawspec 1.0.7 → 1.0.9
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 +1 -1
- package/src/index.ts +8 -0
- package/src/orchestrator/service.ts +122 -49
- package/src/worker/prompts.ts +12 -10
- package/test/assistant-journal.test.ts +35 -0
- package/test/helpers/harness.ts +15 -0
- package/test/queue-planning.test.ts +62 -1
- package/test/queue-work.test.ts +58 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -188,6 +188,14 @@ const plugin = {
|
|
|
188
188
|
}, event.content);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
api.on("before_dispatch", async (event, ctx) => {
|
|
192
|
+
await stateStore.initialize();
|
|
193
|
+
if (!service) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
return await service.handleBeforeDispatch(event, ctx);
|
|
197
|
+
});
|
|
198
|
+
|
|
191
199
|
api.on("before_prompt_build", async (event, ctx) => {
|
|
192
200
|
await stateStore.initialize();
|
|
193
201
|
if (!service) {
|
|
@@ -125,6 +125,14 @@ type PromptBuildContext = {
|
|
|
125
125
|
conversationId?: string;
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
type DispatchContext = {
|
|
129
|
+
sessionKey?: string;
|
|
130
|
+
channel?: string;
|
|
131
|
+
channelId?: string;
|
|
132
|
+
accountId?: string;
|
|
133
|
+
conversationId?: string;
|
|
134
|
+
};
|
|
135
|
+
|
|
128
136
|
type AgentEndEvent = {
|
|
129
137
|
messages: unknown[];
|
|
130
138
|
success: boolean;
|
|
@@ -277,6 +285,28 @@ export class ClawSpecService {
|
|
|
277
285
|
return await this.buildPlanningDiscussionInjection(boundProject, event.prompt);
|
|
278
286
|
}
|
|
279
287
|
|
|
288
|
+
async handleBeforeDispatch(
|
|
289
|
+
event: { content: string; channel?: string },
|
|
290
|
+
ctx: DispatchContext,
|
|
291
|
+
): Promise<{ handled: boolean; text?: string } | void> {
|
|
292
|
+
const keyword = extractEmbeddedClawSpecKeyword(event.content);
|
|
293
|
+
if (!keyword || keyword.kind === "plan") {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const result = await this.executeDirectKeyword(keyword, {
|
|
298
|
+
channel: event.channel,
|
|
299
|
+
channelId: ctx.channelId,
|
|
300
|
+
accountId: ctx.accountId,
|
|
301
|
+
conversationId: ctx.conversationId,
|
|
302
|
+
sessionKey: ctx.sessionKey,
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
handled: true,
|
|
306
|
+
text: result.text ?? "",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
280
310
|
private async handleKeywordPrompt(
|
|
281
311
|
keyword: ClawSpecKeywordIntent,
|
|
282
312
|
event: PromptBuildEvent,
|
|
@@ -298,78 +328,89 @@ export class ClawSpecService {
|
|
|
298
328
|
}
|
|
299
329
|
return this.buildPluginReplyInjection(event.prompt, planningSync.text ?? "");
|
|
300
330
|
}
|
|
331
|
+
case "work":
|
|
332
|
+
case "continue": {
|
|
333
|
+
const result = await this.executeDirectKeyword(keyword, ctx);
|
|
334
|
+
return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
|
|
335
|
+
}
|
|
336
|
+
case "attach": {
|
|
337
|
+
const result = await this.executeDirectKeyword(keyword, ctx);
|
|
338
|
+
return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
|
|
339
|
+
}
|
|
340
|
+
case "detach": {
|
|
341
|
+
const result = await this.executeDirectKeyword(keyword, ctx);
|
|
342
|
+
return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
|
|
343
|
+
}
|
|
344
|
+
case "pause": {
|
|
345
|
+
const result = await this.executeDirectKeyword(keyword, ctx);
|
|
346
|
+
return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
|
|
347
|
+
}
|
|
348
|
+
case "status": {
|
|
349
|
+
const result = await this.executeDirectKeyword(keyword, ctx);
|
|
350
|
+
return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
|
|
351
|
+
}
|
|
352
|
+
case "cancel": {
|
|
353
|
+
const result = await this.executeDirectKeyword(keyword, ctx);
|
|
354
|
+
return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private async executeDirectKeyword(
|
|
360
|
+
keyword: ClawSpecKeywordIntent,
|
|
361
|
+
ctx: DispatchContext,
|
|
362
|
+
): Promise<PluginCommandResult> {
|
|
363
|
+
const match = await this.resolveControlProjectForPromptContext(ctx);
|
|
364
|
+
|
|
365
|
+
switch (keyword.kind) {
|
|
301
366
|
case "work":
|
|
302
367
|
case "continue": {
|
|
303
368
|
if (!match?.project.repoPath || !match.project.changeName) {
|
|
304
|
-
return
|
|
305
|
-
event.prompt,
|
|
306
|
-
"Select a project and create a change first with `/clawspec use <project-name>` and `/clawspec proposal <change-name> [description]`.",
|
|
307
|
-
);
|
|
369
|
+
return errorReply("Select a project and create a change first with `/clawspec use <project-name>` and `/clawspec proposal <change-name> [description]`.");
|
|
308
370
|
}
|
|
309
371
|
if (["archived", "cancelled"].includes(match.project.status)) {
|
|
310
|
-
return
|
|
311
|
-
event.prompt,
|
|
312
|
-
`Change \`${match.project.changeName}\` is no longer active. Create a new proposal before starting implementation again.`,
|
|
313
|
-
);
|
|
372
|
+
return errorReply(`Change \`${match.project.changeName}\` is no longer active. Create a new proposal before starting implementation again.`);
|
|
314
373
|
}
|
|
315
374
|
if (match.project.status === "planning" || match.project.execution?.action === "plan") {
|
|
316
|
-
return
|
|
317
|
-
event.prompt,
|
|
318
|
-
`Planning sync for \`${match.project.changeName}\` is still running. Wait for it to finish before starting implementation.`,
|
|
319
|
-
);
|
|
375
|
+
return errorReply(`Planning sync for \`${match.project.changeName}\` is still running. Wait for it to finish before starting implementation.`);
|
|
320
376
|
}
|
|
321
377
|
if (keyword.kind === "work" && requiresPlanningSync(match.project)) {
|
|
322
|
-
return
|
|
323
|
-
event.prompt,
|
|
324
|
-
buildPlanningRequiredMessage(match.project),
|
|
325
|
-
);
|
|
378
|
+
return errorReply(buildPlanningRequiredMessage(match.project));
|
|
326
379
|
}
|
|
327
380
|
if (match.project.execution?.state === "running" || match.project.status === "running") {
|
|
328
|
-
return
|
|
329
|
-
event.prompt,
|
|
330
|
-
`Background execution for \`${match.project.changeName}\` is already running.`,
|
|
331
|
-
);
|
|
381
|
+
return okReply(`Background execution for \`${match.project.changeName}\` is already running.`);
|
|
332
382
|
}
|
|
333
|
-
|
|
383
|
+
return keyword.kind === "continue"
|
|
334
384
|
? await this.continueProject(match.channelKey)
|
|
335
385
|
: await this.queueWorkProject(match.channelKey, "apply");
|
|
336
|
-
return this.buildPluginReplyInjection(event.prompt, result.text ?? "");
|
|
337
386
|
}
|
|
338
|
-
case "attach":
|
|
387
|
+
case "attach":
|
|
339
388
|
if (!match) {
|
|
340
|
-
return
|
|
389
|
+
return errorReply("No active ClawSpec project is bound to this chat.");
|
|
341
390
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
case "detach": {
|
|
391
|
+
return await this.attachProject(match.channelKey, ctx.sessionKey);
|
|
392
|
+
case "detach":
|
|
346
393
|
if (!match) {
|
|
347
|
-
return
|
|
394
|
+
return errorReply("No active ClawSpec project is bound to this chat.");
|
|
348
395
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
case "pause": {
|
|
396
|
+
return await this.detachProject(match.channelKey);
|
|
397
|
+
case "pause":
|
|
353
398
|
if (!match) {
|
|
354
|
-
return
|
|
399
|
+
return errorReply("No active ClawSpec project is bound to this chat.");
|
|
355
400
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
case "status": {
|
|
401
|
+
return await this.pauseProject(match.channelKey);
|
|
402
|
+
case "status":
|
|
360
403
|
if (!match) {
|
|
361
|
-
return
|
|
404
|
+
return errorReply("No active ClawSpec project is bound to this chat.");
|
|
362
405
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
case "cancel": {
|
|
406
|
+
return await this.projectStatus(match.channelKey);
|
|
407
|
+
case "cancel":
|
|
367
408
|
if (!match) {
|
|
368
|
-
return
|
|
409
|
+
return errorReply("No active ClawSpec project is bound to this chat.");
|
|
369
410
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
411
|
+
return await this.cancelProject(match.channelKey);
|
|
412
|
+
case "plan":
|
|
413
|
+
return errorReply("`cs-plan` must run through the visible planning turn.");
|
|
373
414
|
}
|
|
374
415
|
}
|
|
375
416
|
|
|
@@ -750,13 +791,19 @@ export class ClawSpecService {
|
|
|
750
791
|
for (const artifactId of artifactIds) {
|
|
751
792
|
const result = await this.openSpec.instructionsArtifact(project.repoPath, artifactId, project.changeName);
|
|
752
793
|
results.push(result);
|
|
753
|
-
|
|
794
|
+
const instructionFile = path.join(repoStatePaths.planningInstructionsRoot, `${artifactId}.json`);
|
|
795
|
+
await writeJsonFile(instructionFile, {
|
|
754
796
|
generatedAt: new Date().toISOString(),
|
|
755
797
|
command: result.command,
|
|
756
798
|
cwd: result.cwd,
|
|
757
799
|
durationMs: result.durationMs,
|
|
758
800
|
instruction: result.parsed,
|
|
759
801
|
});
|
|
802
|
+
const relativeInstructionFile = normalizeSlashes(path.relative(project.repoPath, instructionFile) || instructionFile);
|
|
803
|
+
await this.sendChannelUpdate(
|
|
804
|
+
project.channelKey,
|
|
805
|
+
`\`${result.command}\` done. Wrote \`${relativeInstructionFile}\`.`,
|
|
806
|
+
);
|
|
760
807
|
}
|
|
761
808
|
|
|
762
809
|
return results;
|
|
@@ -904,7 +951,8 @@ export class ClawSpecService {
|
|
|
904
951
|
return;
|
|
905
952
|
}
|
|
906
953
|
|
|
907
|
-
const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
|
|
954
|
+
const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
|
|
955
|
+
?? await this.findPlanningProjectByContext(ctx);
|
|
908
956
|
if (planningProject) {
|
|
909
957
|
await this.finalizePlanningTurn(planningProject, event);
|
|
910
958
|
return;
|
|
@@ -2267,6 +2315,27 @@ export class ClawSpecService {
|
|
|
2267
2315
|
);
|
|
2268
2316
|
}
|
|
2269
2317
|
|
|
2318
|
+
private async findPlanningProjectByContext(ctx: PromptBuildContext): Promise<ProjectState | undefined> {
|
|
2319
|
+
const routingContext = deriveRoutingContext(ctx);
|
|
2320
|
+
if (!routingContext.channelId) {
|
|
2321
|
+
return undefined;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
const match = await this.stateStore.findActiveProjectForMessage({
|
|
2325
|
+
channel: routingContext.channel,
|
|
2326
|
+
channelId: routingContext.channelId,
|
|
2327
|
+
accountId: routingContext.accountId,
|
|
2328
|
+
conversationId: routingContext.conversationId,
|
|
2329
|
+
});
|
|
2330
|
+
if (!match) {
|
|
2331
|
+
return undefined;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
return match.project.status === "planning" && match.project.phase === "planning_sync"
|
|
2335
|
+
? match.project
|
|
2336
|
+
: undefined;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2270
2339
|
private async findDiscussionProjectBySessionKey(sessionKey?: string): Promise<ProjectState | undefined> {
|
|
2271
2340
|
if (!sessionKey) {
|
|
2272
2341
|
return undefined;
|
|
@@ -3125,6 +3194,10 @@ function isPassiveAssistantPlanningMessage(text: string): boolean {
|
|
|
3125
3194
|
return true;
|
|
3126
3195
|
}
|
|
3127
3196
|
|
|
3197
|
+
if (/^heartbeat(?:[_\s-]?(?:ok|ping|alive))?$/i.test(normalized)) {
|
|
3198
|
+
return true;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3128
3201
|
const collapsed = normalized.replace(/\s+/g, " ").trim();
|
|
3129
3202
|
if (/^[▶✓⚠↻ℹ]/u.test(collapsed)) {
|
|
3130
3203
|
return true;
|
package/src/worker/prompts.ts
CHANGED
|
@@ -190,16 +190,18 @@ export function buildPlanningPrependContext(params: {
|
|
|
190
190
|
"1. Read planning-journal.jsonl, .openspec.yaml, and any planning artifacts that already exist.",
|
|
191
191
|
"2. Treat the prefetched OpenSpec instruction files in this context as the authoritative source for artifact structure, output paths, and writing guidance.",
|
|
192
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.
|
|
194
|
-
"5.
|
|
195
|
-
"6.
|
|
196
|
-
"7.
|
|
197
|
-
"8.
|
|
198
|
-
"9.
|
|
199
|
-
"10.
|
|
200
|
-
"11.
|
|
201
|
-
"12.
|
|
202
|
-
"13.
|
|
193
|
+
"4. The allowed planning scope is limited to what is explicitly supported by the current user message, the planning journal, existing dependency artifacts, and the prefetched OpenSpec instructions.",
|
|
194
|
+
"5. Do not invent endpoints, features, constraints, files, acceptance criteria, test scenarios, or architecture details that are not grounded in those sources.",
|
|
195
|
+
"6. If there is no substantive planning change, say so clearly in chat and do not rewrite artifacts unnecessarily.",
|
|
196
|
+
"7. If artifacts are missing or stale, update `proposal`, `specs`, `design`, and `tasks` in dependency order using those prefetched OpenSpec instruction files.",
|
|
197
|
+
"8. Do not generate or rewrite planning artifacts from ad-hoc structure guesses; follow OpenSpec instruction/template constraints only.",
|
|
198
|
+
"9. Before updating each artifact, post a short chat update naming the artifact you are about to refresh.",
|
|
199
|
+
"10. After updating each artifact, post a short chat update describing what changed and what artifact comes next.",
|
|
200
|
+
"11. For any implementation-oriented task item, ensure tasks.md contains an explicit testing task (new tests or updated tests) and a validation command.",
|
|
201
|
+
"12. Stop after planning artifacts are refreshed and apply-ready. Do not implement code in this turn.",
|
|
202
|
+
"13. End with a concise summary and a mandatory final line exactly in this shape: `Next: run `cs-work` to start implementation.`",
|
|
203
|
+
"14. Never scan sibling directories under `openspec/changes`, never switch to another change, and never restore or rewrite unrelated files.",
|
|
204
|
+
"15. Do not claim that OpenSpec instructions were skipped in this sync turn. The plugin already executed those commands before this turn began.",
|
|
203
205
|
]
|
|
204
206
|
: [
|
|
205
207
|
"Discussion rules for this turn:",
|
|
@@ -166,3 +166,38 @@ test("inbound bot/system messages are ignored by planning journal capture", asyn
|
|
|
166
166
|
assert.equal(project?.planningJournal?.dirty, false);
|
|
167
167
|
assert.equal(project?.planningJournal?.entryCount, 0);
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
test("heartbeat assistant replies are not appended to the planning journal", async () => {
|
|
171
|
+
const harness = await createServiceHarness("clawspec-ignore-heartbeat-journal-");
|
|
172
|
+
const { service, repoPath } = harness;
|
|
173
|
+
const channelKey = "discord:ignore-heartbeat-journal:default:main";
|
|
174
|
+
const promptContext = createPromptContext("ignore-heartbeat-journal");
|
|
175
|
+
const userPrompt = "Add one more API endpoint.";
|
|
176
|
+
|
|
177
|
+
await service.startProject(channelKey);
|
|
178
|
+
await service.useProject(channelKey, "demo-app");
|
|
179
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
180
|
+
await service.recordPlanningMessageFromContext(promptContext, userPrompt);
|
|
181
|
+
await service.handleBeforePromptBuild(
|
|
182
|
+
{ prompt: userPrompt, messages: [] },
|
|
183
|
+
promptContext,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await service.handleAgentEnd(
|
|
187
|
+
{
|
|
188
|
+
success: true,
|
|
189
|
+
messages: [
|
|
190
|
+
{ role: "user", content: userPrompt },
|
|
191
|
+
{ role: "assistant", content: "HEARTBEAT_OK" },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
promptContext,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
198
|
+
const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
|
|
199
|
+
const entries = await journalStore.list("demo-change");
|
|
200
|
+
|
|
201
|
+
assert.equal(entries.length, 1);
|
|
202
|
+
assert.equal(entries[0]?.role, "user");
|
|
203
|
+
});
|
package/test/helpers/harness.ts
CHANGED
|
@@ -72,6 +72,7 @@ export async function createServiceHarness(prefix: string): Promise<{
|
|
|
72
72
|
memoryStore: ProjectMemoryStore;
|
|
73
73
|
workspaceStore: WorkspaceStore;
|
|
74
74
|
watcherManager: FakeWatcherManager;
|
|
75
|
+
sentMessages: Array<{ to: string; text: string }>;
|
|
75
76
|
workspacePath: string;
|
|
76
77
|
repoPath: string;
|
|
77
78
|
changeDir: string;
|
|
@@ -174,6 +175,7 @@ export async function createServiceHarness(prefix: string): Promise<{
|
|
|
174
175
|
} as Record<string, any>;
|
|
175
176
|
|
|
176
177
|
const watcherManager = createFakeWatcherManager();
|
|
178
|
+
const sentMessages: Array<{ to: string; text: string }> = [];
|
|
177
179
|
const service = new ClawSpecService({
|
|
178
180
|
api: {
|
|
179
181
|
config: {
|
|
@@ -189,6 +191,18 @@ export async function createServiceHarness(prefix: string): Promise<{
|
|
|
189
191
|
],
|
|
190
192
|
},
|
|
191
193
|
},
|
|
194
|
+
runtime: {
|
|
195
|
+
channel: {
|
|
196
|
+
discord: {
|
|
197
|
+
sendMessageDiscord: async (to: string, text: string) => {
|
|
198
|
+
sentMessages.push({ to, text });
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
telegram: {},
|
|
202
|
+
slack: {},
|
|
203
|
+
signal: {},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
192
206
|
logger: createLogger(),
|
|
193
207
|
} as any,
|
|
194
208
|
config: {
|
|
@@ -221,6 +235,7 @@ export async function createServiceHarness(prefix: string): Promise<{
|
|
|
221
235
|
memoryStore,
|
|
222
236
|
workspaceStore,
|
|
223
237
|
watcherManager,
|
|
238
|
+
sentMessages,
|
|
224
239
|
workspacePath,
|
|
225
240
|
repoPath,
|
|
226
241
|
changeDir,
|
|
@@ -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, openSpec } = harness;
|
|
136
|
+
const { service, stateStore, repoPath, openSpec, sentMessages } = harness;
|
|
137
137
|
const channelKey = "discord:visible-plan:default:main";
|
|
138
138
|
const promptContext = {
|
|
139
139
|
trigger: "user",
|
|
@@ -168,9 +168,28 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
|
|
|
168
168
|
assert.match(injected?.prependContext ?? "", /openspec instructions proposal --change demo-change --json/);
|
|
169
169
|
assert.match(injected?.prependContext ?? "", /planning-instructions[\\/]+proposal\.json/);
|
|
170
170
|
assert.deepEqual(instructionCalls, ["proposal", "specs", "design", "tasks"]);
|
|
171
|
+
assert.match(injected?.prependContext ?? "", /allowed planning scope is limited/i);
|
|
172
|
+
assert.match(injected?.prependContext ?? "", /Do not invent endpoints, features, constraints, files, acceptance criteria, test scenarios, or architecture details/i);
|
|
171
173
|
assert.match(injected?.prependContext ?? "", /mandatory final line exactly in this shape/i);
|
|
172
174
|
assert.equal(runningProject?.status, "planning");
|
|
173
175
|
assert.equal(runningProject?.phase, "planning_sync");
|
|
176
|
+
assert.deepEqual(
|
|
177
|
+
sentMessages.map((entry) => entry.to),
|
|
178
|
+
[
|
|
179
|
+
"channel:visible-plan",
|
|
180
|
+
"channel:visible-plan",
|
|
181
|
+
"channel:visible-plan",
|
|
182
|
+
"channel:visible-plan",
|
|
183
|
+
],
|
|
184
|
+
);
|
|
185
|
+
assert.match(sentMessages[0]?.text ?? "", /openspec instructions proposal --change demo-change --json/);
|
|
186
|
+
assert.match(sentMessages[0]?.text ?? "", /planning-instructions\/proposal\.json/);
|
|
187
|
+
assert.match(sentMessages[1]?.text ?? "", /openspec instructions specs --change demo-change --json/);
|
|
188
|
+
assert.match(sentMessages[1]?.text ?? "", /planning-instructions\/specs\.json/);
|
|
189
|
+
assert.match(sentMessages[2]?.text ?? "", /openspec instructions design --change demo-change --json/);
|
|
190
|
+
assert.match(sentMessages[2]?.text ?? "", /planning-instructions\/design\.json/);
|
|
191
|
+
assert.match(sentMessages[3]?.text ?? "", /openspec instructions tasks --change demo-change --json/);
|
|
192
|
+
assert.match(sentMessages[3]?.text ?? "", /planning-instructions\/tasks\.json/);
|
|
174
193
|
|
|
175
194
|
await service.handleAgentEnd(
|
|
176
195
|
{ messages: [], success: true, durationMs: 10 },
|
|
@@ -190,6 +209,48 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
|
|
|
190
209
|
assert.equal(snapshot?.changeName, "demo-change");
|
|
191
210
|
});
|
|
192
211
|
|
|
212
|
+
test("cs-plan finalizes and writes a fresh snapshot even if agent_end sessionKey changes", async () => {
|
|
213
|
+
const harness = await createServiceHarness("clawspec-visible-plan-fallback-");
|
|
214
|
+
const { service, stateStore, repoPath } = harness;
|
|
215
|
+
const channelKey = "discord:visible-plan-fallback:default:main";
|
|
216
|
+
const promptContext = {
|
|
217
|
+
trigger: "user",
|
|
218
|
+
channel: "discord",
|
|
219
|
+
channelId: "visible-plan-fallback",
|
|
220
|
+
accountId: "default",
|
|
221
|
+
conversationId: "main",
|
|
222
|
+
sessionKey: "agent:main:discord:channel:visible-plan-fallback",
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
await service.startProject(channelKey);
|
|
226
|
+
await service.useProject(channelKey, "demo-app");
|
|
227
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
228
|
+
await service.recordPlanningMessageFromContext(promptContext, "add another API endpoint");
|
|
229
|
+
|
|
230
|
+
await service.handleBeforePromptBuild(
|
|
231
|
+
{ prompt: "cs-plan", messages: [] },
|
|
232
|
+
promptContext,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
await service.handleAgentEnd(
|
|
236
|
+
{ messages: [], success: true, durationMs: 10 },
|
|
237
|
+
{
|
|
238
|
+
...promptContext,
|
|
239
|
+
sessionKey: "agent:main:discord:channel:visible-plan-fallback:other",
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const finalized = await stateStore.getActiveProject(channelKey);
|
|
244
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
245
|
+
const snapshot = await readJsonFile<any>(repoStatePaths.planningJournalSnapshotFile, null);
|
|
246
|
+
|
|
247
|
+
assert.equal(finalized?.status, "ready");
|
|
248
|
+
assert.equal(finalized?.phase, "tasks");
|
|
249
|
+
assert.equal(finalized?.planningJournal?.dirty, false);
|
|
250
|
+
assert.equal(snapshot?.changeName, "demo-change");
|
|
251
|
+
assert.equal(snapshot?.entryCount, 1);
|
|
252
|
+
});
|
|
253
|
+
|
|
193
254
|
test("cs-plan clears stale execution control artifacts from earlier worker runs", async () => {
|
|
194
255
|
const harness = await createServiceHarness("clawspec-visible-plan-cleanup-");
|
|
195
256
|
const { service, stateStore, repoPath } = harness;
|
package/test/queue-work.test.ts
CHANGED
|
@@ -65,6 +65,64 @@ test("work queues background implementation", async () => {
|
|
|
65
65
|
assert.deepEqual(watcherManager.wakeCalls, [channelKey]);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("before_dispatch intercepts cs-work and queues background implementation directly", async () => {
|
|
69
|
+
const harness = await createServiceHarness("clawspec-work-dispatch-");
|
|
70
|
+
const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
|
|
71
|
+
const channelKey = "discord:work-dispatch:default:main";
|
|
72
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
73
|
+
await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
|
|
74
|
+
|
|
75
|
+
harness.openSpec.instructionsApply = async (cwd: string, changeName: string) => ({
|
|
76
|
+
command: `openspec instructions apply --change ${changeName} --json`,
|
|
77
|
+
cwd,
|
|
78
|
+
stdout: "{}",
|
|
79
|
+
stderr: "",
|
|
80
|
+
durationMs: 1,
|
|
81
|
+
parsed: {
|
|
82
|
+
changeName,
|
|
83
|
+
changeDir,
|
|
84
|
+
schemaName: "spec-driven",
|
|
85
|
+
contextFiles: { tasks: tasksPath },
|
|
86
|
+
progress: { total: 1, complete: 0, remaining: 1 },
|
|
87
|
+
tasks: [{ id: "1.1", description: "Build the demo endpoint", done: false }],
|
|
88
|
+
state: "ready",
|
|
89
|
+
instruction: "Implement the remaining task.",
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await seedPlanningProject(stateStore, channelKey, {
|
|
94
|
+
workspacePath,
|
|
95
|
+
repoPath,
|
|
96
|
+
projectName: "demo-app",
|
|
97
|
+
changeName: "queue-work",
|
|
98
|
+
changeDir,
|
|
99
|
+
phase: "tasks",
|
|
100
|
+
status: "ready",
|
|
101
|
+
planningDirty: false,
|
|
102
|
+
});
|
|
103
|
+
await stateStore.updateProject(channelKey, (current) => ({
|
|
104
|
+
...current,
|
|
105
|
+
boundSessionKey: "agent:main:discord:channel:work-dispatch",
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const result = await service.handleBeforeDispatch(
|
|
109
|
+
{ content: "cs-work", channel: "discord" },
|
|
110
|
+
{
|
|
111
|
+
channelId: "work-dispatch",
|
|
112
|
+
accountId: "default",
|
|
113
|
+
conversationId: "main",
|
|
114
|
+
sessionKey: "agent:main:discord:channel:work-dispatch",
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
const project = await stateStore.getActiveProject(channelKey);
|
|
118
|
+
|
|
119
|
+
assert.equal(result?.handled, true);
|
|
120
|
+
assert.match(result?.text ?? "", /Execution Queued/);
|
|
121
|
+
assert.equal(project?.status, "armed");
|
|
122
|
+
assert.equal(project?.execution?.action, "work");
|
|
123
|
+
assert.deepEqual(watcherManager.wakeCalls, [channelKey]);
|
|
124
|
+
});
|
|
125
|
+
|
|
68
126
|
test("main chat agent end does not clear a background worker run", async () => {
|
|
69
127
|
const harness = await createServiceHarness("clawspec-work-session-");
|
|
70
128
|
const { service, stateStore, repoPath, workspacePath, changeDir } = harness;
|